Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2716b9e607 | ||
|
|
6dadde47b0 | ||
|
|
ead5ade30e | ||
|
|
d53b20ecd7 | ||
|
|
9010e5b5f7 | ||
|
|
97d9c2b0c7 | ||
|
|
e0ef580f73 | ||
|
|
98cfa3cf4f | ||
|
|
121b4d60e7 | ||
|
|
bb5419182c | ||
|
|
e6c3d0d876 | ||
|
|
26305cacf9 | ||
|
|
b8af4341ab | ||
|
|
b080e98388 | ||
|
|
d2f9b16d1a | ||
|
|
607c38d7e6 | ||
|
|
7492feab42 | ||
|
|
c8f8076579 | ||
|
|
aea948fd4d | ||
|
|
adcd8fefd2 | ||
|
|
4053c4d038 | ||
|
|
ff6b44bc32 | ||
|
|
3e01614939 | ||
|
|
7229abe50d | ||
|
|
b9c0a344b6 | ||
|
|
8e80e2618e | ||
|
|
a4e33f192f | ||
|
|
76e7cc7c18 | ||
|
|
571e2bd28a | ||
|
|
6dbe025ec1 | ||
|
|
2fb505c0eb | ||
|
|
8dfc223fb6 | ||
|
|
d2efea2845 | ||
|
|
6c0289faf7 | ||
|
|
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 | ||
|
|
cb386023d0 | ||
|
|
9bd0aae9d8 | ||
|
|
47463a9623 | ||
|
|
b6ea1a5910 | ||
|
|
41df7ae56f | ||
|
|
9b69117bea | ||
|
|
6ff7fa9bd7 | ||
|
|
1ba7225e83 | ||
|
|
d1b5a94d15 | ||
|
|
ba619747c5 | ||
|
|
5e0077dc11 | ||
|
|
b160515905 | ||
|
|
9ec9f9a010 | ||
|
|
58598df095 | ||
|
|
3d850a282f | ||
|
|
a80d3c3359 | ||
|
|
1719b5f668 | ||
|
|
62bfc6068a | ||
|
|
0a7413903d | ||
|
|
696ef15168 | ||
|
|
add1fbb045 | ||
|
|
d835ad7466 | ||
|
|
9aa51e5250 | ||
|
|
e49f165d0b | ||
|
|
409390fff1 | ||
|
|
4c637d4071 | ||
|
|
432b916cba | ||
|
|
16ed7b281b | ||
|
|
42867aabf4 | ||
|
|
02858f486b | ||
|
|
faae3d3901 | ||
|
|
9b33df6625 | ||
|
|
5d5cef9fcd | ||
|
|
1fa03a3cbb | ||
|
|
4ae125a3ef | ||
|
|
25fbea8522 | ||
|
|
e1dc93d147 | ||
|
|
eb4796cf61 | ||
|
|
1df183b104 | ||
|
|
820aa4432e | ||
|
|
eb12f102f2 | ||
|
|
f5eb27df86 | ||
|
|
44d45e1092 | ||
|
|
ec9d5f78fe | ||
|
|
51ad3ce115 | ||
|
|
6681c3b47c | ||
|
|
9a1c911e53 | ||
|
|
bfeeefab94 | ||
|
|
840757240b | ||
|
|
9422bf9c28 | ||
|
|
603f1c0fa4 | ||
|
|
9f7b3c5405 | ||
|
|
fadce52dce | ||
|
|
6abf112765 | ||
|
|
367dc207b3 | ||
|
|
2bfebe378c | ||
|
|
f8db2225bf | ||
|
|
8c113a70b8 | ||
|
|
fc75847580 | ||
|
|
9ee87dcf2d | ||
|
|
b302446986 | ||
|
|
5191efd891 | ||
|
|
a466838440 | ||
|
|
1d5672e4fe | ||
|
|
e3b1e0310e | ||
|
|
40be71a3f1 | ||
|
|
093b86fe7e | ||
|
|
d79514302f | ||
|
|
70983e19fe | ||
|
|
75829fa442 | ||
|
|
c8d66ca3cd | ||
|
|
1ef1fdeac9 | ||
|
|
05c3447ebb | ||
|
|
f84733d128 | ||
|
|
3def525902 | ||
|
|
5cd67be982 | ||
|
|
f480c5b520 | ||
|
|
6f20e6cc03 | ||
|
|
efbd7b1d04 | ||
|
|
6f698d0ba5 | ||
|
|
2497508271 | ||
|
|
8633711b1d | ||
|
|
ea4651b1f8 | ||
|
|
bd4b3bca94 | ||
|
|
0c34b3595e | ||
|
|
01fd3b6fd4 | ||
|
|
eda90ed310 | ||
|
|
5ebd1bb525 | ||
|
|
6647d4879c | ||
|
|
6709325c38 | ||
|
|
ebec49db3d | ||
|
|
03b4fdabaf | ||
|
|
d116eda0bc | ||
|
|
21b8461525 | ||
|
|
bd687d27d4 | ||
|
|
06f5835211 | ||
|
|
017b40f761 | ||
|
|
288e4763a3 | ||
|
|
5714c739de | ||
|
|
7ef6ef86cd | ||
|
|
c449a176f9 | ||
|
|
8355794e45 | ||
|
|
b3f885f215 | ||
|
|
3f2c7c7033 | ||
|
|
eef60ff0b9 | ||
|
|
4df81ef1da | ||
|
|
c6912b63e7 | ||
|
|
18aa99cb95 | ||
|
|
d2bb936784 | ||
|
|
43087a79f8 | ||
|
|
421fee81c3 | ||
|
|
72b2098030 | ||
|
|
e03d3ac6c6 | ||
|
|
c802be812d | ||
|
|
9a531a61d9 | ||
|
|
9c4edc4dc9 | ||
|
|
9786dc1456 | ||
|
|
ef060d4ee5 | ||
|
|
1a3fbfb3d8 | ||
|
|
6be8980c17 | ||
|
|
5b9406dd91 | ||
|
|
77da63edac | ||
|
|
aa8eaf3f01 | ||
|
|
52775178dc | ||
|
|
1a550895c3 | ||
|
|
4badecbf57 | ||
|
|
eb697ab1c0 | ||
|
|
db5efc9b83 | ||
|
|
31965fd837 | ||
|
|
5544a0304d | ||
|
|
8157e68cf1 | ||
|
|
6337cd87f8 | ||
|
|
822cda2833 | ||
|
|
8c17a2228b | ||
|
|
545dc404e5 | ||
|
|
81da190ac1 | ||
|
|
020926234e | ||
|
|
9fa1787d01 | ||
|
|
59f62cace1 | ||
|
|
439865616a | ||
|
|
4675a25c18 | ||
|
|
449d8a6943 | ||
|
|
9fe2a592f3 | ||
|
|
908fb253d7 | ||
|
|
43297795f5 | ||
|
|
c0b52ac8bd | ||
|
|
b68833f320 | ||
|
|
d88dcb999f | ||
|
|
613fb27ba0 | ||
|
|
989379e924 | ||
|
|
f1c9cc0644 | ||
|
|
68c1a7272a | ||
|
|
399e2e53e6 | ||
|
|
92ede3b970 | ||
|
|
46c2abbca8 | ||
|
|
bbe39b475f | ||
|
|
6162817ea3 | ||
|
|
182d8b5143 | ||
|
|
8a9a354649 | ||
|
|
1de663a8d3 | ||
|
|
d3381b208b | ||
|
|
c5575aab27 | ||
|
|
fbc924c240 | ||
|
|
1ee32822c3 | ||
|
|
251c204a6e | ||
|
|
a4783d18e7 | ||
|
|
d89e74880b | ||
|
|
e0b1c27f1f | ||
|
|
a7cc69b61e | ||
|
|
fe7c245f5c | ||
|
|
aa03de4045 | ||
|
|
4791b2e760 | ||
|
|
196c8c09f7 | ||
|
|
2e42ed83f0 | ||
|
|
7642dcec24 | ||
|
|
76b9cf36a8 | ||
|
|
70afe40b00 | ||
|
|
a1aba1527a | ||
|
|
017c96baa5 | ||
|
|
06934bccab | ||
|
|
30bbf5483f | ||
|
|
6e02a1f574 | ||
|
|
26bd9286ad | ||
|
|
31a4e7576a | ||
|
|
e59b25c816 | ||
|
|
8c5612a0a3 | ||
|
|
e5034e177d | ||
|
|
766a96e817 | ||
|
|
80a7468523 | ||
|
|
f862892544 | ||
|
|
c392ed81ee | ||
|
|
904742ca4b | ||
|
|
dcd7c9a444 | ||
|
|
9f3fdb26e2 | ||
|
|
7a6a6075c2 | ||
|
|
1770fac51f | ||
|
|
97bc5093bd | ||
|
|
7980a8a219 | ||
|
|
358cfe017c | ||
|
|
b44f78b09a | ||
|
|
df6745e820 | ||
|
|
48c5202de2 | ||
|
|
1291b378c0 | ||
|
|
7365b4c471 | ||
|
|
5dfe70d68d | ||
|
|
7aa3bc447d | ||
|
|
8858ca9b8a | ||
|
|
7acbefd60d | ||
|
|
68addfda05 | ||
|
|
fcf2c73589 | ||
|
|
33e261b224 | ||
|
|
fb90e6239a | ||
|
|
9d2937115c | ||
|
|
3f5c0f60e4 | ||
|
|
45b011af9f | ||
|
|
3261135126 | ||
|
|
3de400b740 | ||
|
|
5883d10889 | ||
|
|
58d9a17b8a | ||
|
|
7ea21c3ca2 | ||
|
|
15fb79e30e | ||
|
|
a59980898f | ||
|
|
617a4566be | ||
|
|
6f27567acd | ||
|
|
64e80c9bb6 | ||
|
|
40ebca7f07 | ||
|
|
12f50ef2dc | ||
|
|
0a26fb598b | ||
|
|
07250a221a | ||
|
|
517ae236b3 | ||
|
|
a70b5a2f6a | ||
|
|
6c7563bd15 | ||
|
|
4da3cfc787 | ||
|
|
ff43c7c9aa | ||
|
|
303983bd3e | ||
|
|
6526a0b0e3 | ||
|
|
395a9907d7 | ||
|
|
4e3c0b60c1 | ||
|
|
5f9e77a500 | ||
|
|
f0b0db9aaa | ||
|
|
dc6d9985d8 | ||
|
|
d34c2e8568 | ||
|
|
9239353239 | ||
|
|
fe0c5a4f1b | ||
|
|
4075cc6501 | ||
|
|
16dbe70014 | ||
|
|
17f7e2b168 | ||
|
|
838f7a5e14 | ||
|
|
e7fb276ee9 | ||
|
|
736f0a0839 | ||
|
|
727f3702d0 | ||
|
|
f9212d01b7 |
@@ -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,166 @@
|
||||
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", "initialize-account", "bootstrap-admin"} {
|
||||
t.Run(sub, func(t *testing.T) {
|
||||
dir, s := cliStore(t)
|
||||
if sub != "bootstrap-admin" {
|
||||
if _, e := s.BootstrapFirstAdminWrite(store.AccountWrite{Owner: "root", Name: "Root", Actor: "test"}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
out := filepath.Join(t.TempDir(), "secret")
|
||||
args := []string{sub, "--data", dir, "--owner", "ada", "--out-file", out}
|
||||
if sub == "initialize-account" {
|
||||
a, e := s.CreatePendingAccount("root", "ada", store.AccountProfile{Name: "Ada"})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
args = append(args, "--user-id", a.ID, "--expected-state-version", "1", "--expected-generation", "0")
|
||||
}
|
||||
rc, _ := accountCLI(t, args...)
|
||||
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)
|
||||
}
|
||||
}
|
||||
+328
-29
@@ -9,17 +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":
|
||||
@@ -30,6 +41,14 @@ func main() {
|
||||
os.Exit(runConfig(os.Args[2:]))
|
||||
case "user":
|
||||
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)
|
||||
@@ -44,22 +63,49 @@ func main() {
|
||||
instance := fs.String("instance", "", "本进程标识(空 = 主机名:监听地址;启动对账只收口这个实例的 run)")
|
||||
insecure := fs.Bool("insecure-listen", false, "允许在非回环地址上不开启认证(每次启动都会警告)")
|
||||
allowedHosts := fs.String("allowed-hosts", "", "逗号分隔的额外主机名,供反向代理的 Host 与 Origin 校验")
|
||||
noExec := fs.Bool("disable-exec-tools", false, "硬关闭执行原语 run_shell/run_python/register_artifact(用户设置无法覆盖;多用户部署在执行隔离就位前建议开启)")
|
||||
execBackend := fs.String("exec-backend", "", "执行后端:direct(默认,现状直启)| sandbox(bwrap 隔离档:断网、只读系统根、仅会话目录可写、进程数限额)")
|
||||
execDepsDir := fs.String("exec-deps-dir", "", "sandbox 档只读依赖层目录(site-packages 等;空=不挂载)")
|
||||
execWorkspace := fs.String("exec-workspace", "", "sandbox 档工作区模式:空=直写会话目录 | overlay=冻结基底+私有 upper+CAS 回收(S0)")
|
||||
tier := fs.String("tier", "", "部署档:空=auto(DSN→PG,否则 SQLite)| local(SQLite,不允许 DSN)| team(PG fail-closed:缺 DSN/未认证拒绝启动,PG 故障拒绝就绪)")
|
||||
pgMaxOpen := fs.Int("pg-max-open", 0, "PG 最大打开连接(0=默认 16)")
|
||||
pgMaxIdle := fs.Int("pg-max-idle", 0, "PG 最大空闲连接(0=默认 8)")
|
||||
pgIdleSec := fs.Int("pg-conn-max-idle-sec", 0, "PG 空闲连接回收秒数(0=默认 300)")
|
||||
pgLifetimeSec := fs.Int("pg-conn-max-lifetime-sec", 0, "PG 连接最长生命周期秒数(0=不限)")
|
||||
pgQueryTimeoutSec := fs.Int("pg-query-timeout-sec", 0, "PG statement_timeout 秒数(0=不限;覆盖全部业务查询)")
|
||||
fs.Parse(os.Args[1:])
|
||||
cliSet := visited(fs)
|
||||
|
||||
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
|
||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
||||
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
|
||||
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts,
|
||||
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
|
||||
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace,
|
||||
Tier: *tier, PGMaxOpen: *pgMaxOpen, PGMaxIdle: *pgMaxIdle,
|
||||
PGConnMaxIdleSec: *pgIdleSec, PGConnMaxLifetimeSec: *pgLifetimeSec, PGQueryTimeoutSec: *pgQueryTimeoutSec,
|
||||
}, cliSet)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if msgs := resolved.TierProblems(); len(msgs) > 0 {
|
||||
for _, msg := range msgs {
|
||||
fmt.Fprintln(os.Stderr, "agent-system: tier guard:", msg)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := app.Run(app.Options{
|
||||
Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock,
|
||||
StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode,
|
||||
InstanceID: resolved.Instance, InsecureListen: resolved.InsecureListen, AllowedHosts: resolved.AllowedHosts(),
|
||||
DisableExecTools: resolved.DisableExecTools, ExecBackend: resolved.ExecBackend, ExecDepsDir: resolved.ExecDepsDir,
|
||||
ExecWorkspace: resolved.ExecWorkspace,
|
||||
Tier: resolved.Tier,
|
||||
PGPool: store.PoolOptions{
|
||||
MaxOpenConn: resolved.PGMaxOpen, MaxIdleConn: resolved.PGMaxIdle,
|
||||
ConnMaxIdleTimeSec: resolved.PGConnMaxIdleSec, ConnMaxLifetimeSec: resolved.PGConnMaxLifetimeSec,
|
||||
QueryTimeoutSec: resolved.PGQueryTimeoutSec,
|
||||
},
|
||||
}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
os.Exit(1)
|
||||
@@ -76,6 +122,18 @@ func visited(fs *flag.FlagSet) map[string]bool {
|
||||
name = "insecureListen"
|
||||
case "allowed-hosts":
|
||||
name = "allowedHosts"
|
||||
case "disable-exec-tools":
|
||||
name = "disableExecTools"
|
||||
case "pg-max-open":
|
||||
name = "pgMaxOpen"
|
||||
case "pg-max-idle":
|
||||
name = "pgMaxIdle"
|
||||
case "pg-conn-max-idle-sec":
|
||||
name = "pgConnMaxIdleSec"
|
||||
case "pg-conn-max-lifetime-sec":
|
||||
name = "pgConnMaxLifetimeSec"
|
||||
case "pg-query-timeout-sec":
|
||||
name = "pgQueryTimeoutSec"
|
||||
}
|
||||
set[name] = true
|
||||
})
|
||||
@@ -100,11 +158,24 @@ func runConfig(args []string) int {
|
||||
instance := fs.String("instance", "", "")
|
||||
insecure := fs.Bool("insecure-listen", false, "")
|
||||
allowedHosts := fs.String("allowed-hosts", "", "")
|
||||
noExec := fs.Bool("disable-exec-tools", false, "")
|
||||
execBackend := fs.String("exec-backend", "", "")
|
||||
execDepsDir := fs.String("exec-deps-dir", "", "")
|
||||
execWorkspace := fs.String("exec-workspace", "", "")
|
||||
tier := fs.String("tier", "", "")
|
||||
pgMaxOpen := fs.Int("pg-max-open", 0, "")
|
||||
pgMaxIdle := fs.Int("pg-max-idle", 0, "")
|
||||
pgIdleSec := fs.Int("pg-conn-max-idle-sec", 0, "")
|
||||
pgLifetimeSec := fs.Int("pg-conn-max-lifetime-sec", 0, "")
|
||||
pgQueryTimeoutSec := fs.Int("pg-query-timeout-sec", 0, "")
|
||||
fs.Parse(args[1:])
|
||||
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
|
||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
||||
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
|
||||
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts,
|
||||
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
|
||||
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace,
|
||||
Tier: *tier, PGMaxOpen: *pgMaxOpen, PGMaxIdle: *pgMaxIdle,
|
||||
PGConnMaxIdleSec: *pgIdleSec, PGConnMaxLifetimeSec: *pgLifetimeSec, PGQueryTimeoutSec: *pgQueryTimeoutSec,
|
||||
}, visited(fs))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
@@ -125,22 +196,47 @@ func runAPIKey(args []string) int {
|
||||
fs := flag.NewFlagSet("apikey "+sub, flag.ExitOnError)
|
||||
data := fs.String("data", "./data", "数据目录")
|
||||
dsn := fs.String("dsn", "", "PostgreSQL 档 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":
|
||||
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:", err)
|
||||
return 1
|
||||
}
|
||||
// 明文仅此一次输出;库中只存 SHA-256 摘要。
|
||||
fmt.Println(key)
|
||||
if delivery.file == nil {
|
||||
fmt.Println(key) // Existing API-key CLI compatibility; owner grants require a file.
|
||||
return 0
|
||||
}
|
||||
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)
|
||||
@@ -169,55 +265,222 @@ func runAPIKey(args []string) int {
|
||||
}
|
||||
}
|
||||
|
||||
// runUser:用户账号管理子命令(管理员发放制,R2)——create / reset-password / list。
|
||||
// 密码未显式给定时生成随机值,明文仅此一次输出;创建/重置后首次登录强制改密。
|
||||
// deliverCredential writes only to a new private file. The secret never goes
|
||||
// to stdout or an existing path; a JSON payload may include expiresAt.
|
||||
func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) error {
|
||||
if outPath == "" {
|
||||
return fmt.Errorf("protected credential delivery path required")
|
||||
}
|
||||
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 fmt.Errorf("protected credential delivery path required")
|
||||
}
|
||||
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:本地 owner 账号初始化、续办和只读/撤销操作。
|
||||
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|initialize-account|list|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(空 = 嵌入式档;已弃用,优先 AGENT_SYSTEM_DSN 或 --dsn-file)")
|
||||
dsnFile := fs.String("dsn-file", "", "0600 权限的 DSN 文件")
|
||||
owner := fs.String("owner", "", "用户名(owner)")
|
||||
userID := fs.String("user-id", "", "待初始化账号稳定ID")
|
||||
expectedStateVersion := fs.Int64("expected-state-version", 0, "待初始化账号状态版本")
|
||||
expectedGeneration := fs.Int64("expected-generation", -1, "待初始化账号代数")
|
||||
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, "旧随机初始密码参数(现已拒绝)")
|
||||
fs.String("purpose", "recover", "旧 issue-login-key 参数(现已拒绝)")
|
||||
fs.Int("ttl-minutes", 30, "旧 issue-login-key 参数(现已拒绝)")
|
||||
fs.Bool("revoke-all-credentials", false, "旧 issue-login-key 参数(现已拒绝)")
|
||||
keyID := fs.String("id", "", "revoke-login-key 凭据编号")
|
||||
out := fs.String("out", "", "凭据保护交付文件(0600 JSON)")
|
||||
fs.Parse(args[1:])
|
||||
if sub == "reset-password" || sub == "issue-login-key" {
|
||||
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: use owner initialization or owner-confirmed recovery")
|
||||
return 2 // reject before DSN/password reads, storage opens or output reservation
|
||||
}
|
||||
if sub == "create" && (*password != "" || *passwordStdin || *generatePassword) {
|
||||
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: local creation uses trusted activation only")
|
||||
return 2
|
||||
}
|
||||
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 == "initialize-account" || sub == "bootstrap-admin"
|
||||
if credentialAction && *outFile == "" && *out == "" {
|
||||
fmt.Fprintln(os.Stderr, "user: --out or --out-file is required for protected credential delivery")
|
||||
return 2
|
||||
}
|
||||
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:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println(pw)
|
||||
if *password == "" {
|
||||
fmt.Fprintln(os.Stderr, "初始密码仅此一次输出;该用户首次登录须修改密码。")
|
||||
if err := delivery.deliver("login-key", *owner, pw, nil); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user create:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "operation_id=%s\n", opID)
|
||||
return 0
|
||||
case "initialize-account":
|
||||
if *userID == "" || *expectedStateVersion < 1 || *expectedGeneration < 0 {
|
||||
fmt.Fprintln(os.Stderr, "user initialize-account: --user-id、--expected-state-version、--expected-generation 必填")
|
||||
return 2
|
||||
}
|
||||
plain, targetOwner, err := app.InitializeAccount(*dsn, *data, *userID, *expectedStateVersion, *expectedGeneration)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user initialize-account:", err)
|
||||
return 1
|
||||
}
|
||||
if err := delivery.deliver("activation", targetOwner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user initialize-account:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case "reset-password":
|
||||
if *owner == "" {
|
||||
fmt.Fprintln(os.Stderr, "user reset-password: --owner 必填")
|
||||
return 2
|
||||
}
|
||||
pw, err := app.ResetUserPassword(*dsn, *data, *owner, *password)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user reset-password:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println(pw)
|
||||
if *password == "" {
|
||||
fmt.Fprintln(os.Stderr, "新密码仅此一次输出;该用户下次登录须修改密码(其余会话已吊销)。")
|
||||
}
|
||||
return 0
|
||||
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED")
|
||||
return 2
|
||||
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)
|
||||
return 1
|
||||
@@ -225,8 +488,44 @@ func runUser(args []string) int {
|
||||
out, _ := json.MarshalIndent(users, "", " ")
|
||||
fmt.Println(string(out))
|
||||
return 0
|
||||
case "issue-login-key":
|
||||
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED")
|
||||
return 2
|
||||
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:", 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:", 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
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "user: 未知子命令", sub)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// runIdentity:U1 I3 服务实例库内身份(manifest.instance_id ↔ agent_system_identity)。
|
||||
// 与 serve --instance(runs 进程标识)无关。实现在 pkg/identity(公共化:下游
|
||||
// 组合仓共用同一 CLI,未知子命令 usage 退出、不落服务路径——2026-10-12 裁定)。
|
||||
func runIdentity(args []string) int {
|
||||
return identity.Run(args)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLegacyUserCredentialCommandsStopBeforeOpeningStorage(t *testing.T) {
|
||||
data := t.TempDir()
|
||||
for _, args := range [][]string{
|
||||
{"reset-password", "--data", data, "--owner", "victim", "--password", "attacker-password"},
|
||||
{"issue-login-key", "--data", data, "--owner", "victim", "--purpose", "recover", "--revoke-all-credentials"},
|
||||
} {
|
||||
if code := runUser(args); code != 2 {
|
||||
t.Fatalf("legacy command did not reject before storage: %v code=%d", args[0], code)
|
||||
}
|
||||
}
|
||||
out := filepath.Join(data, "delivery.json")
|
||||
if code := runUser([]string{"create", "--data", data, "--owner", "victim", "--password", "attacker-password", "--out", out}); code != 2 {
|
||||
t.Fatalf("password-bearing local create accepted: %d", code)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(data, "agent-system.db")); !os.IsNotExist(err) {
|
||||
t.Fatalf("rejected commands opened/created a database: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(out); !os.IsNotExist(err) {
|
||||
t.Fatalf("rejected commands wrote a delivery file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialDeliveryCreatesExclusive0600File(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "credential.json")
|
||||
if err := os.WriteFile(out, []byte("old"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := deliverCredential(out, "activation", "alice", "secret", nil); err == nil {
|
||||
t.Fatal("existing permissive file was overwritten")
|
||||
}
|
||||
bytes, err := os.ReadFile(out)
|
||||
if err != nil || string(bytes) != "old" {
|
||||
t.Fatalf("existing file changed: %q %v", bytes, err)
|
||||
}
|
||||
out = filepath.Join(dir, "new.json")
|
||||
if err := deliverCredential(out, "activation", "alice", "secret", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(out)
|
||||
if err != nil || info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("delivery mode: %v %v", info, err)
|
||||
}
|
||||
bytes, err = os.ReadFile(out)
|
||||
if err != nil || !strings.Contains(string(bytes), `"secret": "secret"`) {
|
||||
t.Fatalf("delivery payload: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// U4 集成(环境门控 AGENT_PG_TEST_DSN:CI test-pg job / 本机隔离实例):
|
||||
// 夹具 → 受管迁移 → 校验和验证 → 幂等重跑 → 断点续传 → 非空目标拒绝 →
|
||||
// 未知 owner 阻塞。每用例独立 PG schema(与 store 测试同隔离约定)。
|
||||
|
||||
func pgSchemaDSN(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("AGENT_PG_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("AGENT_PG_TEST_DSN 未设置:真库集成由 CI 的必跑 job 覆盖")
|
||||
}
|
||||
schema := "mig_" + strings.ToLower(store.NewID("t")) + "_" + fmt.Sprint(os.Getpid())
|
||||
admin, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("preinstall pg_trgm: %v", err)
|
||||
}
|
||||
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("create schema: %v", err)
|
||||
}
|
||||
cleanup := func() {
|
||||
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
|
||||
admin.Close()
|
||||
}
|
||||
return store.AppendPGRuntimeParam(dsn, "search_path", schema+",public"), cleanup
|
||||
}
|
||||
|
||||
func TestMigrateEndToEndAndIdempotent(t *testing.T) {
|
||||
target, cleanup := pgSchemaDSN(t)
|
||||
defer cleanup()
|
||||
fixture := buildFixture(t)
|
||||
src, err := sql.Open("sqlite", fixture)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer src.Close()
|
||||
var sourcePublic, sourceStaff, sourcePrivate, sourceSecret string
|
||||
var sourceKeyVersion int64
|
||||
if err := src.QueryRow(`SELECT public_revision,staff_no_revision,private_revision,cursor_key_version,cursor_secret FROM account_directory_revision WHERE id=1`).Scan(
|
||||
&sourcePublic, &sourceStaff, &sourcePrivate, &sourceKeyVersion, &sourceSecret,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-e2e"}); err != nil {
|
||||
t.Fatalf("首次迁移:%v", err)
|
||||
}
|
||||
// 幂等重跑(同身份):表级 done 对账,零重复行。
|
||||
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-e2e"}); err != nil {
|
||||
t.Fatalf("幂等重跑:%v", err)
|
||||
}
|
||||
// 目标侧关键断言:布尔映射、NULL 保留、Unicode、BLOB、SERIAL 序列推进、
|
||||
// 复合主键事件序。
|
||||
st, err := store.OpenPostgres(target, filepath.Join(t.TempDir(), "artifacts"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
db := st.DB()
|
||||
var targetPublic, targetStaff, targetPrivate, targetSecret string
|
||||
var targetKeyVersion int64
|
||||
if err := db.QueryRow(`SELECT public_revision,staff_no_revision,private_revision,cursor_key_version,cursor_secret FROM account_directory_revision WHERE id=1`).Scan(
|
||||
&targetPublic, &targetStaff, &targetPrivate, &targetKeyVersion, &targetSecret,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if targetPublic != sourcePublic || targetStaff != sourceStaff || targetPrivate != sourcePrivate || targetKeyVersion != sourceKeyVersion || targetSecret != sourceSecret {
|
||||
t.Fatal("迁移后的目录版本或游标密钥与源库不一致")
|
||||
}
|
||||
var pinned, archived bool
|
||||
if err := db.QueryRow(`SELECT pinned, archived FROM conversations WHERE id='c1'`).Scan(&pinned, &archived); err != nil || !pinned || archived {
|
||||
t.Fatalf("布尔映射:pinned=%v archived=%v err=%v", pinned, archived, err)
|
||||
}
|
||||
var title, note string
|
||||
if err := db.QueryRow(`SELECT title, session_note FROM conversations WHERE id='c1'`).Scan(&title, ¬e); err != nil || !strings.Contains(note, "注") {
|
||||
t.Fatalf("Unicode:title=%q note=%q err=%v", title, note, err)
|
||||
}
|
||||
var vec []byte
|
||||
if err := db.QueryRow(`SELECT vec FROM knowledge_vectors WHERE doc_id='k1'`).Scan(&vec); err != nil || len(vec) != 12 {
|
||||
t.Fatalf("BLOB 向量:len=%d err=%v", len(vec), err)
|
||||
}
|
||||
var ended sql.NullString
|
||||
if err := db.QueryRow(`SELECT ended_at FROM runs WHERE id='r1'`).Scan(&ended); err != nil || ended.Valid {
|
||||
t.Fatalf("NULL 保留:ended=%v err=%v", ended, err)
|
||||
}
|
||||
var seqMax int
|
||||
if err := db.QueryRow(`SELECT MAX(seq) FROM events WHERE conversation_id='c1'`).Scan(&seqMax); err != nil || seqMax != 2 {
|
||||
t.Fatalf("事件复合主键序:max=%d err=%v", seqMax, err)
|
||||
}
|
||||
// 复制的显式 id 之后,应用侧插入不撞序列(command_audit.id=7 已复制)。
|
||||
if _, err := db.Exec(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, payload, owner) VALUES ('2026-10-11T03:00:00Z','cmd-2','http','chat','done','op-2','c1','{}','alice')`); err != nil {
|
||||
t.Fatalf("序列推进失败(复制后插入撞主键):%v", err)
|
||||
}
|
||||
// 报告不含秘密值。
|
||||
var report string
|
||||
if err := db.QueryRow(`SELECT report FROM migration_control WHERE migration_id='mig-e2e'`).Scan(&report); err != nil || strings.Contains(report, "sk-secret-value") {
|
||||
t.Fatalf("报告泄密:err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateResumeAfterInterrupt(t *testing.T) {
|
||||
target, cleanup := pgSchemaDSN(t)
|
||||
defer cleanup()
|
||||
fixture := buildFixture(t)
|
||||
|
||||
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-resume"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 模拟断点:messages 已 done 后被部分回退(行删一半、状态回 pending),
|
||||
// 重跑只重做该表,终态校验仍全绿。
|
||||
db, err := sql.Open("pgx", target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(`DELETE FROM messages WHERE id='m3'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE migration_table_state SET state='pending' WHERE migration_id='mig-resume' AND table_name='messages'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-resume"}); err != nil {
|
||||
t.Fatalf("断点续跑:%v", err)
|
||||
}
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM messages`).Scan(&n); err != nil || n != 3 {
|
||||
t.Fatalf("续跑后 messages=%d:重做该表应恢复 3 行", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateRefusesForeignDataAndWrongSnapshot(t *testing.T) {
|
||||
target, cleanup := pgSchemaDSN(t)
|
||||
defer cleanup()
|
||||
fixture := buildFixture(t)
|
||||
|
||||
// 非空目标(人工数据、无迁移身份):拒绝。
|
||||
st, err := store.OpenPostgres(target, filepath.Join(t.TempDir(), "artifacts"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Close()
|
||||
db, err := sql.Open("pgx", target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(`INSERT INTO projects (id, name, settings, created_at, updated_at, owner) VALUES ('foreign', '外来', '{}', '2026-10-11T00:00:00Z', '2026-10-11T00:00:00Z', 'alice')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-x"})
|
||||
if err == nil || !strings.Contains(err.Error(), "非空目标") {
|
||||
t.Fatalf("非空目标应拒绝:%v", err)
|
||||
}
|
||||
if _, err := db.Exec(`DELETE FROM projects WHERE id='foreign'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 同 migration_id 换快照:拒绝(仅同一冻结快照可续传)。
|
||||
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-snap"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := buildFixture(t) // 不同文件 ⇒ 不同 snapshot_id
|
||||
err = cmdMigrate([]string{"--source", other, "--target", target, "--migration-id", "mig-snap"})
|
||||
if err == nil || !strings.Contains(err.Error(), "快照不同") {
|
||||
t.Fatalf("换快照应拒绝:%v", err)
|
||||
}
|
||||
|
||||
// 其他 migration_id 已存在:拒绝。
|
||||
err = cmdMigrate([]string{"--source", other, "--target", target, "--migration-id", "mig-other"})
|
||||
if err == nil || !strings.Contains(err.Error(), "其他迁移记录") {
|
||||
t.Fatalf("他身份应拒绝:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateBlocksUnknownOwner(t *testing.T) {
|
||||
target, cleanup := pgSchemaDSN(t)
|
||||
defer cleanup()
|
||||
fixture := buildFixture(t)
|
||||
// 在夹具里造一个 users 之外的 owner 行(直接写源库)。
|
||||
srcPath := fixture
|
||||
st, err := store.OpenSQLite(srcPath) // 普通打开(夹具可写)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.DB().Exec(`INSERT INTO knowledge (id, project_id, source, title, ref, chunk, created_at, owner) VALUES
|
||||
('k_ghost', 'p1', 'upload', '幽灵', 'g.md', '…', '2026-10-11T01:00:00Z', 'ghost-user')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Close()
|
||||
err = cmdMigrate([]string{"--source", srcPath, "--target", target, "--migration-id", "mig-ghost"})
|
||||
if err == nil || !strings.Contains(err.Error(), "未知 owner") {
|
||||
t.Fatalf("未知 owner 应阻塞:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,876 @@
|
||||
// storage-migrate:SQLite → PostgreSQL 受管迁移入口(协作存储方案 U4/R7)。
|
||||
//
|
||||
// 纪律(方案 §5 U4 / §6):
|
||||
// - 只处理 Backend 库;不执行 SQLite dump 作为 PG SQL(按行复制 + 类型映射);
|
||||
// - 迁移身份(migration_id + source_snapshot_id + source_schema_version)落
|
||||
// 目标库 migration_control:仅同一冻结快照可续传;目标已含其他数据即拒绝;
|
||||
// - 报告只含计数、校验和与归属检查结论——不含 DSN、密钥与行正文(KTD14)。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// storage-migrate inventory --source agent-system.db [--artifacts dir] [--out report.json]
|
||||
// storage-migrate backup --source agent-system.db --out snapshot.db
|
||||
// storage-migrate migrate --source snapshot.db --target "postgres://…" \
|
||||
// --migration-id <名> [--artifacts dir] [--out report.json]
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite" // SQLite 驱动(源,只读打开)
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
var err error
|
||||
switch os.Args[1] {
|
||||
case "inventory":
|
||||
err = cmdInventory(os.Args[2:])
|
||||
case "backup":
|
||||
err = cmdBackup(os.Args[2:])
|
||||
case "migrate":
|
||||
err = cmdMigrate(os.Args[2:])
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "storage-migrate:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "用法:storage-migrate <inventory|backup|migrate> [flags](-h 看各子命令 flags)")
|
||||
}
|
||||
|
||||
// ---- 公共小件 ----
|
||||
|
||||
func openSourceRO(path string) (*sql.DB, error) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil, fmt.Errorf("source %s: %w", path, err)
|
||||
}
|
||||
// query_only:迁移源只读(冻结快照,任何写入都是缺陷)。
|
||||
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=query_only(1)", path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("source %s: %w", path, err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, int64, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
n, err := io.Copy(h, f)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), n, nil
|
||||
}
|
||||
|
||||
func nowISO() string { return time.Now().UTC().Format(time.RFC3339Nano) }
|
||||
|
||||
// ---- inventory:只读盘点(§6.1) ----
|
||||
|
||||
func cmdInventory(args []string) error {
|
||||
fs := flag.NewFlagSet("inventory", flag.ExitOnError)
|
||||
source := fs.String("source", "", "SQLite 库路径(活跃库或冻结快照)")
|
||||
artifacts := fs.String("artifacts", "", "工件对象目录(可选;盘点文件清单与哈希)")
|
||||
out := fs.String("out", "", "报告输出 JSON 路径(缺省打印到 stdout)")
|
||||
fs.Parse(args)
|
||||
if *source == "" {
|
||||
return fmt.Errorf("--source 必填")
|
||||
}
|
||||
db, err := openSourceRO(*source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
rep, err := inventoryReport(db, *source, *artifacts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeReport(rep, *out)
|
||||
}
|
||||
|
||||
func inventoryReport(db *sql.DB, source, artifactsDir string) (map[string]any, error) {
|
||||
version, err := sourceSchemaVersion(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var mode string
|
||||
if err := db.QueryRow(`PRAGMA journal_mode`).Scan(&mode); err != nil {
|
||||
return nil, fmt.Errorf("journal_mode: %w", err)
|
||||
}
|
||||
var chk string
|
||||
if err := db.QueryRow(`PRAGMA integrity_check`).Scan(&chk); err != nil || chk != "ok" {
|
||||
return nil, fmt.Errorf("integrity_check: %v (%q)", err, chk)
|
||||
}
|
||||
tables, err := sqliteTableCounts(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap, size, err := fileSHA256(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rep := map[string]any{
|
||||
"command": "inventory", "source": source, "source_sha256": snap,
|
||||
"source_bytes": size, "source_schema_version": version,
|
||||
"journal_mode": mode, "integrity": chk, "table_counts": tables,
|
||||
"generated_at": nowISO(),
|
||||
}
|
||||
if artifactsDir != "" {
|
||||
manifest, err := artifactManifest(artifactsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rep["artifacts"] = manifest
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
func sourceSchemaVersion(db *sql.DB) (int, error) {
|
||||
var v int
|
||||
err := db.QueryRow(`SELECT COALESCE(MAX(version),0) FROM schema_migrations`).Scan(&v)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no such table") {
|
||||
return 0, fmt.Errorf("源库缺 schema_migrations(不是本工具管理的库):%w", err)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// sqliteTableCounts:仅盘点本工具认识的业务表(见 copyOrder)。
|
||||
func sqliteTableCounts(db *sql.DB) (map[string]int64, error) {
|
||||
out := map[string]int64{}
|
||||
for _, t := range copyOrder {
|
||||
var n int64
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM "` + t + `"`).Scan(&n); err != nil {
|
||||
if strings.Contains(err.Error(), "no such table") {
|
||||
continue // 旧库可能没有新表
|
||||
}
|
||||
return nil, fmt.Errorf("count %s: %w", t, err)
|
||||
}
|
||||
out[t] = n
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func artifactManifest(dir string) (map[string]any, error) {
|
||||
type entry struct {
|
||||
Name, SHA string
|
||||
Size int64
|
||||
}
|
||||
var entries []entry
|
||||
hAll := sha256.New()
|
||||
err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
sum, _, err := fileSHA256(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(dir, p)
|
||||
entries = append(entries, entry{rel, sum, info.Size()})
|
||||
fmt.Fprintf(hAll, "%s %d %s\n", rel, info.Size(), sum)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("artifacts %s: %w", dir, err)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
|
||||
return map[string]any{
|
||||
"dir": dir, "files": entries, "file_count": len(entries),
|
||||
"manifest_sha256": hex.EncodeToString(hAll.Sum(nil)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---- backup:一致性快照(§6.2:VACUUM INTO + 可打开校验 + 计数对账) ----
|
||||
|
||||
func cmdBackup(args []string) error {
|
||||
fs := flag.NewFlagSet("backup", flag.ExitOnError)
|
||||
source := fs.String("source", "", "活跃 SQLite 库路径")
|
||||
out := fs.String("out", "", "快照输出路径(不得与源同文件;已存在则拒绝)")
|
||||
fs.Parse(args)
|
||||
if *source == "" || *out == "" {
|
||||
return fmt.Errorf("--source 与 --out 必填")
|
||||
}
|
||||
if same, _ := filepath.EvalSymlinks(*source); same != "" {
|
||||
if abs1, _ := filepath.Abs(same); abs1 != "" {
|
||||
if abs2, _ := filepath.Abs(*out); abs2 == abs1 {
|
||||
return fmt.Errorf("快照输出不得与源同文件")
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(*out); err == nil {
|
||||
return fmt.Errorf("快照输出已存在(不覆盖):%s", *out)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// VACUUM INTO 在线安全(含 WAL 内容);query_only 会禁写,需普通打开。
|
||||
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=busy_timeout(30000)", *source))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(`VACUUM INTO ?`, *out); err != nil {
|
||||
return fmt.Errorf("vacuum into: %w", err)
|
||||
}
|
||||
// 校验:快照可打开、完整性、逐表计数与源一致。
|
||||
snap, err := openSourceRO(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer snap.Close()
|
||||
var chk string
|
||||
if err := snap.QueryRow(`PRAGMA integrity_check`).Scan(&chk); err != nil || chk != "ok" {
|
||||
return fmt.Errorf("快照完整性校验失败:%v (%q)", err, chk)
|
||||
}
|
||||
srcCounts, err := sqliteTableCounts(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapCounts, err := sqliteTableCounts(snap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for t, n := range srcCounts {
|
||||
if snapCounts[t] != n {
|
||||
return fmt.Errorf("快照计数不符:%s 源=%d 快照=%d", t, n, snapCounts[t])
|
||||
}
|
||||
}
|
||||
sum, size, err := fileSHA256(*out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("backup ok: %s(%d 字节,sha256 %s…,%d 表计数一致)\n", *out, size, sum[:12], len(srcCounts))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- migrate:受管迁移(§6.3 + U4) ----
|
||||
|
||||
// copyOrder:按外键依赖排序的业务表全集(projects 最先,引用方在后)。
|
||||
// admission_leases 是运行态执行许可(迁移无意义),明确排除。
|
||||
var copyOrder = []string{
|
||||
"projects", "users", "account_directory_revision", "profiles", "settings", "plugins",
|
||||
"conversations", "runs", "messages", "events", "artifacts",
|
||||
"hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors",
|
||||
"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",
|
||||
"recovery_proposals", "recovery_proposal_proofs",
|
||||
}
|
||||
|
||||
const controlDDL = `
|
||||
CREATE TABLE IF NOT EXISTS migration_control (
|
||||
migration_id TEXT PRIMARY KEY,
|
||||
source_snapshot_id TEXT NOT NULL,
|
||||
source_schema_version INTEGER NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
verified_at TEXT,
|
||||
report TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS migration_table_state (
|
||||
migration_id TEXT NOT NULL,
|
||||
table_name TEXT NOT NULL,
|
||||
source_count INTEGER NOT NULL,
|
||||
target_count INTEGER NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
PRIMARY KEY (migration_id, table_name)
|
||||
);`
|
||||
|
||||
func cmdMigrate(args []string) error {
|
||||
fs := flag.NewFlagSet("migrate", flag.ExitOnError)
|
||||
source := fs.String("source", "", "冻结快照路径(backup 子命令产物;不接受活跃库)")
|
||||
target := fs.String("target", "", "目标 PostgreSQL DSN(须为空库或同身份续传库)")
|
||||
migrationID := fs.String("migration-id", "", "迁移实例名(人工指定,报告与续传身份的一部分)")
|
||||
artifacts := fs.String("artifacts", "", "工件对象目录(可选;仅盘点清单入报告,不复制)")
|
||||
out := fs.String("out", "", "报告输出 JSON 路径(缺省打印到 stdout)")
|
||||
fs.Parse(args)
|
||||
if *source == "" || *target == "" || *migrationID == "" {
|
||||
return fmt.Errorf("--source、--target、--migration-id 必填")
|
||||
}
|
||||
src, err := openSourceRO(*source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
snapID, _, err := fileSHA256(*source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srcVersion, err := sourceSchemaVersion(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 目标:先用 store 同款迁移器建 schema/扩展(OpenPostgres 会跑迁移与
|
||||
// 未知版本门禁),再落控制表。DSN 不回显、不入报告。
|
||||
st, err := store.OpenPostgres(*target, filepath.Join(filepath.Dir(*source), "migrate-artifacts"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("open target: %w", err)
|
||||
}
|
||||
defer st.Close()
|
||||
db := st.DB()
|
||||
if _, err := db.Exec(controlDDL); err != nil {
|
||||
return fmt.Errorf("control tables: %w", err)
|
||||
}
|
||||
if err := guardIdentity(db, *migrationID, snapID, srcVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureControlRow(db, *migrationID, snapID, srcVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rep := map[string]any{
|
||||
"command": "migrate", "migration_id": *migrationID,
|
||||
"source_snapshot_id": snapID, "source_schema_version": srcVersion,
|
||||
"started_at": nowISO(),
|
||||
}
|
||||
var tables []map[string]any
|
||||
for _, t := range copyOrder {
|
||||
if !sqliteHasTable(src, t) {
|
||||
continue // 旧库无此表
|
||||
}
|
||||
entry, err := migrateTable(db, src, *migrationID, t)
|
||||
if err != nil {
|
||||
return fmt.Errorf("table %s: %w", t, err)
|
||||
}
|
||||
tables = append(tables, entry)
|
||||
}
|
||||
// 未知 owner 检查(U4:阻塞,不静默)。'' 与 'local' 是单租户默认身份。
|
||||
if err := checkOwners(db, src); err != nil {
|
||||
return err
|
||||
}
|
||||
// 校验:目标逐表重算计数与校验和,全部一致才落 verified。
|
||||
if err := verifyTables(db, *migrationID, tables); err != nil {
|
||||
return err
|
||||
}
|
||||
var totalSrc, totalDst int64
|
||||
for _, e := range tables {
|
||||
totalSrc += e["source_count"].(int64)
|
||||
totalDst += e["target_count"].(int64)
|
||||
}
|
||||
rep["tables"] = tables
|
||||
rep["rows_total"] = totalSrc
|
||||
rep["verified_at"] = nowISO()
|
||||
if *artifacts != "" {
|
||||
manifest, err := artifactManifest(*artifacts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rep["artifacts"] = manifest
|
||||
}
|
||||
repJSON, _ := json.Marshal(rep)
|
||||
if _, err := db.Exec(`UPDATE migration_control SET finished_at=$1, verified_at=$2, report=$3 WHERE migration_id=$4`,
|
||||
nowISO(), rep["verified_at"], string(repJSON), *migrationID); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeReport(rep, *out)
|
||||
}
|
||||
|
||||
// guardIdentity:目标非空且身份不同即拒绝(R7:不覆盖他人数据)。
|
||||
// - 存在其他 migration_id 的完成记录 → 拒绝;
|
||||
// - 同 migration_id 但 source_snapshot_id 不同(换了快照续传)→ 拒绝;
|
||||
// - 业务表已有数据而控制表为空(人工造过数据)→ 拒绝。
|
||||
func guardIdentity(db *sql.DB, migrationID, snapID string, srcVersion int) error {
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM migration_control WHERE migration_id<>$1`, migrationID).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return fmt.Errorf("目标库已有其他迁移记录(%d 条):拒绝覆盖,换空库或专项回迁方案", n)
|
||||
}
|
||||
var snap, started string
|
||||
var sv int
|
||||
err := db.QueryRow(`SELECT source_snapshot_id, source_schema_version, started_at FROM migration_control WHERE migration_id=$1`, migrationID).Scan(&snap, &sv, &started)
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
// 新迁移:业务表必须为空。
|
||||
busy, err := businessRows(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if busy > 0 {
|
||||
return fmt.Errorf("目标库业务表已有 %d 行且无迁移身份:拒绝(非空目标不得作为迁移目标)", busy)
|
||||
}
|
||||
case err != nil:
|
||||
return err
|
||||
default:
|
||||
if snap != snapID {
|
||||
return fmt.Errorf("同 migration_id 但快照不同(库=%s… 本次=%s…):仅同一冻结快照可续传", snap[:12], snapID[:12])
|
||||
}
|
||||
_ = sv
|
||||
_ = started
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func businessRows(db *sql.DB) (int64, error) {
|
||||
var total int64
|
||||
for _, t := range copyOrder {
|
||||
// Schema setup seeds this singleton before a migration starts. It is
|
||||
// replaced from the source during copying and is not target business data.
|
||||
if t == "account_directory_revision" {
|
||||
continue
|
||||
}
|
||||
var n int64
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM "` + t + `"`).Scan(&n); err != nil {
|
||||
if strings.Contains(err.Error(), "does not exist") {
|
||||
continue
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
total += n
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func ensureControlRow(db *sql.DB, migrationID, snapID string, srcVersion int) error {
|
||||
_, err := db.Exec(`INSERT INTO migration_control (migration_id, source_snapshot_id, source_schema_version, started_at)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT(migration_id) DO NOTHING`, migrationID, snapID, srcVersion, nowISO())
|
||||
return err
|
||||
}
|
||||
|
||||
func sqliteHasTable(db *sql.DB, name string) bool {
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&n); err != nil {
|
||||
return false
|
||||
}
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// migrateTable:done 则对账(幂等重跑);pending 则按行复制(布尔映射 +
|
||||
// 批量 INSERT + 序列推进)后落 done 与源侧校验和。
|
||||
func migrateTable(db *sql.DB, src *sql.DB, migrationID, table string) (map[string]any, error) {
|
||||
cols, pk, err := targetColumns(db, table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var state string
|
||||
var srcCount, dstCountStored int64
|
||||
var srcChecksum string
|
||||
err = db.QueryRow(`SELECT state, source_count, target_count, checksum FROM migration_table_state WHERE migration_id=$1 AND table_name=$2`,
|
||||
migrationID, table).Scan(&state, &srcCount, &dstCountStored, &srcChecksum)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
if err == sql.ErrNoRows {
|
||||
state = "pending"
|
||||
}
|
||||
// 源侧计数与校验和始终重算(冻结快照上必然稳定;差异=快照被换,拒绝)。
|
||||
bools, err := boolColumns(db, table, cols)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
countNow, sumNow, err := sourceScan(src, table, cols, pk, bools)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if state == "done" {
|
||||
if countNow != srcCount || sumNow != srcChecksum {
|
||||
return nil, fmt.Errorf("源与登记不符(疑似换快照):count %d/%d checksum %s…/%s…", countNow, srcCount, sumNow[:12], srcChecksum[:12])
|
||||
}
|
||||
entry := map[string]any{"table": table, "source_count": countNow, "target_count": dstCountStored, "checksum": sumNow, "state": "done(idempotent)"}
|
||||
return entry, nil
|
||||
}
|
||||
if err := copyRows(db, src, table, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dstCount, _, err := targetScan(db, table, cols, pk, bools)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dstCount != countNow {
|
||||
return nil, fmt.Errorf("复制后计数不符:源=%d 目标=%d", countNow, dstCount)
|
||||
}
|
||||
if err := advanceSequences(db, table); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO migration_table_state (migration_id, table_name, source_count, target_count, checksum, state, finished_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
ON CONFLICT(migration_id, table_name) DO UPDATE SET source_count=excluded.source_count, target_count=excluded.target_count, checksum=excluded.checksum, state=excluded.state, finished_at=excluded.finished_at`,
|
||||
migrationID, table, countNow, dstCount, sumNow, "done", nowISO()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"table": table, "source_count": countNow, "target_count": dstCount, "checksum": sumNow, "state": "done"}, nil
|
||||
}
|
||||
|
||||
// targetColumns:目标侧列清单(按名排序)+ 主键列。布尔列集合同取自目标
|
||||
// information_schema(类型映射以 PG schema 为准——SQLite 侧 0/1 → bool)。
|
||||
func targetColumns(db *sql.DB, table string) (cols []string, pk []string, err error) {
|
||||
// 生成列(PG GENERATED ALWAYS,如 knowledge.tsv)与库内向量副本列
|
||||
// (vec_pg:BLOB 权威的派生表示,U6-3)不复制不校验:PG 从业务列
|
||||
// 派生或由 Upsert 双写,源库(SQLite)没有该列。
|
||||
rows, err := db.Query(`
|
||||
SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_schema=current_schema() AND table_name=$1 AND is_generated='NEVER'
|
||||
AND column_name NOT IN ('vec_pg') ORDER BY column_name`, table)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
exists := false
|
||||
for rows.Next() {
|
||||
var name, dtype string
|
||||
if err := rows.Scan(&name, &dtype); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
exists = true
|
||||
cols = append(cols, name)
|
||||
}
|
||||
if !exists {
|
||||
return nil, nil, fmt.Errorf("目标缺表 %s(迁移器未建?)", table)
|
||||
}
|
||||
prows, err := db.Query(`
|
||||
SELECT a.attname FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid=i.indrelid AND a.attnum=ANY(i.indkey)
|
||||
WHERE i.indisprimary AND i.indrelid=$1::regclass`, table)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer prows.Close()
|
||||
for prows.Next() {
|
||||
var p string
|
||||
if err := prows.Scan(&p); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
pk = append(pk, p)
|
||||
}
|
||||
if len(pk) == 0 {
|
||||
pk = cols // 无主键表按全列序校验
|
||||
}
|
||||
return cols, pk, nil
|
||||
}
|
||||
|
||||
func boolColumns(db *sql.DB, table string, cols []string) (map[string]bool, error) {
|
||||
out := map[string]bool{}
|
||||
rows, err := db.Query(`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema=current_schema() AND table_name=$1 AND data_type='boolean'`, table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var c string
|
||||
if err := rows.Scan(&c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[c] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// canonicalRow:稳定行编码(校验和用):列名排序、值规范化
|
||||
// (bool→true/false、[]byte→base64、NULL→null、数值/字符串原样)。
|
||||
func canonicalRow(cols []string, vals []any, bools map[string]bool) string {
|
||||
type kv struct {
|
||||
K string
|
||||
V any
|
||||
}
|
||||
pairs := make([]kv, len(cols))
|
||||
for i := range cols {
|
||||
pairs[i] = kv{cols[i], normalize(vals[i], bools[cols[i]])}
|
||||
}
|
||||
b, _ := json.Marshal(pairs)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// normalize:布尔列按目标 schema 对称归一(源 int64 0/1、目标 bool 编码一致,
|
||||
// 否则校验和必然不符——真库实测);[]byte→base64。
|
||||
func normalize(v any, isBool bool) any {
|
||||
if isBool {
|
||||
return sqliteBool(v)
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case []byte:
|
||||
return base64.StdEncoding.EncodeToString(t)
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// hashChain:按主键序哈希链(前后衔接,顺序敏感)。
|
||||
func hashChain(prev string, rowJSON string) string {
|
||||
h := sha256.Sum256([]byte(prev + "|" + rowJSON))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// scanChecksum:源/目标共用的扫描对账(列清单一致、主键序、行哈希链)。
|
||||
func scanChecksum(rows *sql.Rows, cols []string, bools map[string]bool) (int64, string, error) {
|
||||
vals := make([]any, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
var count int64
|
||||
sum := ""
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
sum = hashChain(sum, canonicalRow(cols, vals, bools))
|
||||
count++
|
||||
}
|
||||
return count, sum, rows.Err()
|
||||
}
|
||||
|
||||
func sourceScan(src *sql.DB, table string, cols, pk []string, bools map[string]bool) (int64, string, error) {
|
||||
orderBy := "ORDER BY " + quoteJoin(pk, `"`)
|
||||
rows, err := src.Query(`SELECT ` + quoteJoin(cols, `"`) + ` FROM "` + table + `" ` + orderBy)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanChecksum(rows, cols, bools)
|
||||
}
|
||||
|
||||
func targetScan(db *sql.DB, table string, cols, pk []string, bools map[string]bool) (int64, string, error) {
|
||||
rows, err := db.Query(`SELECT ` + quoteJoin(cols, `"`) + ` FROM "` + table + `" ORDER BY ` + quoteJoin(pk, `"`))
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanChecksum(rows, cols, bools)
|
||||
}
|
||||
|
||||
func quoteJoin(names []string, q string) string {
|
||||
out := make([]string, len(names))
|
||||
for i, n := range names {
|
||||
out[i] = q + n + q
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
// copyRows:按行复制。布尔列 0/1→false/true;其余原样(TEXT/INT/REAL/BLOB)。
|
||||
func copyRows(db *sql.DB, src *sql.DB, table string, cols []string) error {
|
||||
bools, err := boolColumns(db, table, cols)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
colList := quoteJoin(cols, `"`)
|
||||
ph := make([]string, len(cols))
|
||||
for i := range ph {
|
||||
ph[i] = fmt.Sprintf("$%d", i+1)
|
||||
}
|
||||
placeholders := strings.Join(ph, ",")
|
||||
srcRows, err := src.Query(`SELECT ` + colList + ` FROM "` + table + `"`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcRows.Close()
|
||||
vals := make([]any, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// The destination migrator creates a fresh singleton directory revision at
|
||||
// schema startup. Replace it with the source's opaque revision so existing
|
||||
// cursors/visible-change history remain tied to the migrated account data.
|
||||
if table == "account_directory_revision" {
|
||||
if _, err := tx.Exec(`DELETE FROM account_directory_revision`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
stmt, err := tx.Prepare(fmt.Sprintf(`INSERT INTO "%s" (%s) VALUES (%s) ON CONFLICT DO NOTHING`, table, colList, placeholders))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
var batch int
|
||||
for srcRows.Next() {
|
||||
if err := srcRows.Scan(ptrs...); err != nil {
|
||||
return err
|
||||
}
|
||||
args := make([]any, len(cols))
|
||||
for i, c := range cols {
|
||||
v := vals[i]
|
||||
if bools[c] {
|
||||
args[i] = sqliteBool(v)
|
||||
} else {
|
||||
args[i] = v
|
||||
}
|
||||
}
|
||||
if _, err := stmt.Exec(args...); err != nil {
|
||||
return fmt.Errorf("insert: %w", err)
|
||||
}
|
||||
batch++
|
||||
}
|
||||
if err := srcRows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func sqliteBool(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case int64:
|
||||
return t != 0
|
||||
case bool:
|
||||
return t
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// advanceSequences:SERIAL/identity 列复制后推进序列(显式插入不推进,
|
||||
// 后续应用插入会撞主键)。无序列表为空操作。
|
||||
func advanceSequences(db *sql.DB, table string) error {
|
||||
rows, err := db.Query(`
|
||||
SELECT pg_get_serial_sequence($1, column_name)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema=current_schema() AND table_name=$1
|
||||
AND (column_default LIKE 'nextval%' OR is_identity='YES')`, table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
var seqs []string
|
||||
for rows.Next() {
|
||||
var s any
|
||||
if err := rows.Scan(&s); err != nil {
|
||||
return err
|
||||
}
|
||||
if s != nil {
|
||||
seqs = append(seqs, s.(string))
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, s := range seqs {
|
||||
if _, err := db.Exec(fmt.Sprintf(`SELECT setval('%s', COALESCE((SELECT MAX(id) FROM "%s"), 1))`, s, table)); err != nil {
|
||||
return fmt.Errorf("setval %s: %w", s, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkOwners:业务行引用的 owner 必须存在于 users(单租户默认 ”/'local'
|
||||
// 除外)。未知 owner 阻塞迁移(U4/R7:不静默带过未知归属)。
|
||||
func checkOwners(db *sql.DB, src *sql.DB) error {
|
||||
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards",
|
||||
"project_event_shares", "command_audit", "sessions", "api_keys", "login_keys", "account_events", "recovery_proposals"}
|
||||
for _, t := range ownerTables {
|
||||
if !sqliteHasTable(src, t) {
|
||||
continue
|
||||
}
|
||||
rows, err := src.Query(`SELECT DISTINCT owner FROM "` + t + `" WHERE owner<>'' AND owner<>'local'`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var unknown []string
|
||||
for rows.Next() {
|
||||
var o string
|
||||
if err := rows.Scan(&o); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE owner=$1`, o).Scan(&n); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
unknown = append(unknown, o)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(unknown) > 0 {
|
||||
return fmt.Errorf("表 %s 引用未知 owner(users 无此行):%v——先在目标补建用户或修正归属,再迁移", t, unknown)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyTables:目标逐表重算,与登记的源计数/校验和全部一致才通过。
|
||||
func verifyTables(db *sql.DB, migrationID string, tables []map[string]any) error {
|
||||
for _, e := range tables {
|
||||
table := e["table"].(string)
|
||||
wantCount := e["source_count"].(int64)
|
||||
wantSum := e["checksum"].(string)
|
||||
cols, pk, err := targetColumns(db, table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bools, err := boolColumns(db, table, cols)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gotCount, gotSum, err := targetScan(db, table, cols, pk, bools)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if gotCount != wantCount || gotSum != wantSum {
|
||||
return fmt.Errorf("表 %s 校验不符:count %d/%d checksum %s…/%s…", table, gotCount, wantCount, gotSum[:12], wantSum[:12])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeReport(rep map[string]any, out string) error {
|
||||
b, err := json.MarshalIndent(rep, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out == "" {
|
||||
fmt.Println(string(b))
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(out, append(b, '\n'), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// U4 迁移工具测试:夹具覆盖全部当前 schema 的边值(布尔/空值/时间/JSON/
|
||||
// Unicode/二进制向量/秘密字段),以及 inventory/backup 的行为面。
|
||||
// 真库端到端(幂等/续传/非空拒/未知 owner)在 integration_test.go(PG 门控)。
|
||||
|
||||
// buildFixture:建一个全 schema 的 SQLite 库并填充边值行,返回路径。
|
||||
func buildFixture(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "fixture.db")
|
||||
st, err := store.OpenSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
seedEdgeRows(t, st)
|
||||
return path
|
||||
}
|
||||
|
||||
func seedEdgeRows(t *testing.T, st *store.Store) {
|
||||
t.Helper()
|
||||
// users(含 is_admin/must_change 布尔)与单租户默认身份。
|
||||
mustExec(t, st, `INSERT INTO users (id, owner, name, created_at, password_hash, password_set_at, must_change_password, is_admin) VALUES
|
||||
('u1', 'alice', '爱丽丝', '2026-10-11T01:00:00Z', 'sha256:…', '2026-10-11T01:00:00Z', false, true),
|
||||
('u2', 'bob', 'Bob 😊', '2026-10-11T01:00:00Z', '', '', true, false)`)
|
||||
// 项目与会话(布尔 pinned/archived/allow_cross_read/cross_private、空 JSON)。
|
||||
mustExec(t, st, `INSERT INTO projects (id, name, settings, created_at, updated_at, owner) VALUES
|
||||
('p1', '项目一', '{}', '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 'alice'),
|
||||
('p2', 'Emoji 项目 🚀', '{"a":null,"b":[1,2,"三"]}', '2026-10-11T02:00:00Z', '2026-10-11T02:00:00Z', '')`)
|
||||
mustExec(t, st, `INSERT INTO conversations (id, project_id, title, profile_id, created_at, updated_at, owner, pinned, archived, allow_cross_read, cross_private, permission_mode, session_note) VALUES
|
||||
('c1', 'p1', '会话', '', '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 'alice', true, false, false, true, '', 'note—注'),
|
||||
('c2', 'p2', '', '', '2026-10-11T02:00:00Z', '2026-10-11T02:00:00Z', '', false, true, true, false, 'strict', '')`)
|
||||
// 消息(request_id 幂等、parts JSON 含空对象/空数组/Unicode)与 seq 顺序。
|
||||
mustExec(t, st, `INSERT INTO messages (id, conversation_id, run_id, role, parts, seq, created_at, request_id) VALUES
|
||||
('m1', 'c1', '', 'user', '[{"type":"text","text":"你好"}]', 1, '2026-10-11T01:00:01Z', 'req-1'),
|
||||
('m2', 'c1', '', 'assistant', '[{"type":"kv","kv":[]},{"type":"text","text":""}]', 2, '2026-10-11T01:00:02Z', ''),
|
||||
('m3', 'c2', '', 'system', '[]', 1, '2026-10-11T02:00:01Z', 'req-2')`)
|
||||
// runs(NULL ended_at/usage/error)。
|
||||
mustExec(t, st, `INSERT INTO runs (id, parent_run_id, conversation_id, project_id, owner, kind, profile_id, status, phase, agent_name, created_at, instance_id) VALUES
|
||||
('r1', '', 'c1', 'p1', 'alice', 'conversation', '', 'completed', '', '', '2026-10-11T01:00:00Z', 'host:1'),
|
||||
('r2', 'r1', 'c1', 'p1', 'alice', 'subagent', '', 'running', 'thinking', '检索', '2026-10-11T01:00:03Z', 'host:2')`)
|
||||
// events(复合主键 (conversation_id, seq))。
|
||||
mustExec(t, st, `INSERT INTO events (conversation_id, seq, run_id, ts, v, type, payload) VALUES
|
||||
('c1', 1, 'r1', '2026-10-11T01:00:01Z', 1, 'run.phase', '{"phase":"thinking"}'),
|
||||
('c1', 2, 'r1', '2026-10-11T01:00:02Z', 1, 'message.appended', NULL),
|
||||
('c2', 1, '', '2026-10-11T02:00:01Z', 1, 'x', '{}')`)
|
||||
// 工件(size 0 与大值)。
|
||||
mustExec(t, st, `INSERT INTO artifacts (id, project_id, name, kind, mime, size, source_run, content_path, created_at, updated_at, owner) VALUES
|
||||
('a1', 'p1', '报告.md', 'doc', 'text/markdown', 0, 'r1', 'art/a1', '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 'alice'),
|
||||
('a2', 'p2', '数据.xlsx', 'doc', '', 2147483647, '', '', '2026-10-11T02:00:00Z', '2026-10-11T02:00:00Z', '')`)
|
||||
// HITL 卡(resolved=0、resolution NULL)。
|
||||
mustExec(t, st, `INSERT INTO hitl_cards (id, run_id, conversation_id, payload, created_at, resolved, resolution, owner) VALUES
|
||||
('h1', 'r2', 'c1', '{"q":"继续?"}', '2026-10-11T01:00:04Z', 0, NULL, 'alice')`)
|
||||
// 知识 + 向量(BLOB 二进制:float32 little-endian)。
|
||||
mustExec(t, st, `INSERT INTO knowledge (id, project_id, source, title, ref, chunk, created_at, owner) VALUES
|
||||
('k1', 'p1', 'upload', '标题', 'doc.md', '正文内容…', '2026-10-11T01:00:00Z', 'alice')`)
|
||||
mustExec(t, st, `INSERT INTO knowledge_vectors (doc_id, owner, ref, dim, vec, created_at) VALUES
|
||||
('k1', 'alice', 'doc.md', 3, x'0000803F0000003F000080BF', '2026-10-11T01:00:00Z')`)
|
||||
// 活动卡(pinned 布尔)。
|
||||
mustExec(t, st, `INSERT INTO activity_cards (id, project_id, conversation_id, ref, owner, payload, status, pinned, created_at, updated_at, version) VALUES
|
||||
('ac1', 'p1', 'c1', '', 'alice', '{"title":"卡"}', 'active', 1, '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 2)`)
|
||||
// 命令审计(SERIAL 主键:显式 id 插入)。
|
||||
mustExec(t, st, `INSERT INTO command_audit (id, time, command_id, source, entrance, state, operation_id, conversation_id, payload, owner) VALUES
|
||||
(7, '2026-10-11T01:00:00Z', 'cmd-1', 'http', 'chat', 'done', 'op-1', 'c1', '{"actor":"alice"}', 'alice')`)
|
||||
// 设置('' 默认租户 scope)。
|
||||
mustExec(t, st, `INSERT INTO settings (owner, scope, "values") VALUES ('', 'runtime', '{"rateLimitPerMin":0}')`)
|
||||
// 秘密字段(值不外泄:迁移按行复制,报告无正文)。
|
||||
mustExec(t, st, `INSERT INTO secret_keys (id, owner, service, label, value, active, created_at) VALUES
|
||||
('sk1', 'alice', 'openai', 'main', 'sk-secret-value-不打印', 1, '2026-10-11T01:00:00Z')`)
|
||||
// 恢复隔离(业务态,应随迁)。
|
||||
mustExec(t, st, `INSERT INTO recovery_holds (run_id, owner, conversation_id, reason, created_at) VALUES
|
||||
('rh1', 'alice', 'c1', '副作用未知', '2026-10-11T01:00:00Z')`)
|
||||
}
|
||||
|
||||
func must(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// mustExec:夹具直接走底层连接写行(业务代码不得如此;见 store.DB 注释)。
|
||||
func mustExec(t *testing.T, st *store.Store, q string) {
|
||||
t.Helper()
|
||||
if _, err := st.DB().Exec(q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- inventory ----
|
||||
|
||||
func TestInventoryReport(t *testing.T) {
|
||||
path := buildFixture(t)
|
||||
db, err := openSourceRO(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
rep, err := inventoryReport(db, path, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep["source_schema_version"].(int) < 20 {
|
||||
t.Fatalf("schema version: %v", rep["source_schema_version"])
|
||||
}
|
||||
counts := rep["table_counts"].(map[string]int64)
|
||||
if counts["messages"] != 3 || counts["events"] != 3 || counts["users"] != 2 {
|
||||
t.Fatalf("counts: %v", counts)
|
||||
}
|
||||
b, _ := json.Marshal(rep)
|
||||
if strings.Contains(string(b), "sk-secret-value") {
|
||||
t.Fatal("inventory 报告不得包含秘密值")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInventoryMissingFile(t *testing.T) {
|
||||
if _, err := openSourceRO(filepath.Join(t.TempDir(), "nope.db")); err == nil {
|
||||
t.Fatal("缺文件应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- backup ----
|
||||
|
||||
func TestBackupVerifiesAndSnapshotsConsistent(t *testing.T) {
|
||||
path := buildFixture(t)
|
||||
out := filepath.Join(t.TempDir(), "snap.db")
|
||||
if err := cmdBackup([]string{"--source", path, "--out", out}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap, err := openSourceRO(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer snap.Close()
|
||||
var n int
|
||||
if err := snap.QueryRow(`SELECT COUNT(*) FROM messages`).Scan(&n); err != nil || n != 3 {
|
||||
t.Fatalf("snap messages: %d %v", n, err)
|
||||
}
|
||||
// 已存在的输出拒绝覆盖。
|
||||
if err := cmdBackup([]string{"--source", path, "--out", out}); err == nil {
|
||||
t.Fatal("已存在快照应拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 夹具即文档:确认全部业务表在 copyOrder 内(防新表漏迁) ----
|
||||
|
||||
func TestCopyOrderCoversAllFixtureTables(t *testing.T) {
|
||||
path := buildFixture(t)
|
||||
db, err := openSourceRO(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
known := map[string]bool{}
|
||||
for _, t := range copyOrder {
|
||||
known[t] = true
|
||||
}
|
||||
known["schema_migrations"] = true // 迁移器管理,不随迁
|
||||
// 运行态表不随迁:执行许可是进程瞬态;执行租约与跨实例命令属当前
|
||||
// 实例的调度现场(接管语义在 U5 内由 lease/fencing 处理,不复制历史)。
|
||||
for _, rt := range []string{"admission_leases", "conversation_executions", "run_commands"} {
|
||||
known[rt] = true
|
||||
}
|
||||
// FTS5 虚表影子表:SQLite 内部结构,PG 侧由 v4 生成列/索引从 knowledge
|
||||
// 行自动重建,不随迁。
|
||||
for _, shadow := range []string{"knowledge_fts", "knowledge_fts_data", "knowledge_fts_idx", "knowledge_fts_docsize", "knowledge_fts_config"} {
|
||||
known[shadow] = true
|
||||
}
|
||||
var missing []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !known[name] {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
t.Fatalf("copyOrder 漏表(新表加入 schema 后须同步工具清单):%v", missing)
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
Submodule deps/agent-command updated: cc1ad77498...4f583ca3ee
Vendored
+1
-1
Submodule deps/agent-intent updated: 65aaebca1e...343b1e6d9a
Vendored
+1
-1
Submodule deps/agent-native updated: 3a110a728c...c348441691
Vendored
+1
-1
Submodule deps/agent-squad updated: 5a57636cc0...490b0b2dfa
Vendored
+1
-1
Submodule deps/agent-system-core updated: 0b5c7747a3...5773e74086
@@ -0,0 +1,120 @@
|
||||
# IAM U3 A1: account write fence inventory
|
||||
|
||||
Status: stage 4–5 implementation inventory; the fixed candidate is the commit containing this file, and stage 6 human review remains pending. The route table records the **4053c4d baseline finding** and the intended final sink; its `Now` column is historical, not a claim about the amended candidate. Scope follows the approved Documents A1–A4 amendment `fb3a8214df963697ebd745ad11eb852ed9444fe3` and approved delegated-actor amendment `5783940e4c434102cbca8c22bd282d87b28fa840`.
|
||||
|
||||
The 66 non-GET `/api` registrations were enumerated from `internal/server/server.go`. Paths below omit `/api`. `A` means `s.owner(r)` (auth-off: implicit `store.OwnerDefault`); `B` means body owner, `K` redeemed-key owner, `S` current password-session owner. Target resource owner never substitutes for actor. In the historical `Now` column, `T` means the baseline already had a final Store check; `M` means a Backend DB write lacked one; `E` means an external or process-local effect; `—` means no business mutation or permitted credential cleanup. `D` means a short Store transaction with actor account lock and enabled read **before** resource/scope/conversation locks; `F` means stage immutable bytes, then `D` for the visible DB reference; `X` means last effective-state check before external action plus cancellation; `C` means `D` at each command handler's actual sink; `P` means `D` for Backend projection and `X` at the separate RDM callback. `Vnn` identifies the route for traceability; verification uses representative concurrency cases and complete source-to-sink review, rather than one duplicate test per route.
|
||||
|
||||
| # | Route → handler (source line) | Actor | Final sink | Now | Required fence | Verify |
|
||||
|---:|---|---|---|---|---|---|
|
||||
| 01 | `POST /auth/login` → `login` (`accounts.go:148`) | B | `CreateSessionVerified` | T | retain account/version transaction check | V01 |
|
||||
| 02 | `POST /auth/login-key/redeem` → `redeemLoginKey` (`accounts.go:106`) | K | `RedeemLoginKey`; failed-attempt security counter | T | retain account/grant transaction check | V02 |
|
||||
| 03 | `POST /auth/recovery-grants/redeem` → `redeemLoginKey` (`accounts.go:106`) | K | same as 02 | T | same as 02 | V03 |
|
||||
| 04 | `POST /auth/logout` → `logout` (`accounts.go:202`) | A | `DeleteSession` credential cleanup | — | permit cleanup after disable | V04 |
|
||||
| 05 | `POST /auth/password` → `changePassword` (`accounts.go:214`) | S | checked setup/forced/active password Store transaction | T | retain session/account recheck | V05 |
|
||||
| 06 | `POST /admin/users` → `createAdminUser` (`accounts.go:321`) | A | `CreatePendingAccount` | T | retain actor enabled/admin transaction check | V06 |
|
||||
| 07 | `PATCH /me/profile` → `patchMeProfile` (`accounts.go:394`) | S | `PatchAccountProfile` | T | retain actor/target transaction check | V07 |
|
||||
| 08 | `PATCH /admin/users/{userId}/profile` → `patchAdminAccountProfile` (`accounts.go:419`) | A | `PatchAccountProfile` | T | retain actor/target transaction check | V08 |
|
||||
| 09 | `PUT /admin/users/{userId}/state` → `setAdminAccountState` (`accounts.go:444`) | A | `SetAccountEnabled` | T | retain shared account lock and in-tx actor check | V09 |
|
||||
| 10 | `POST /admin/users/{userId}/recovery-proposals` → `createAdminRecoveryProposal` (`recovery.go:7`) | A | `CreateRecoveryProposal` | T | retain actor/target transaction check | V10 |
|
||||
| 11 | `DELETE /admin/users/{userId}/recovery-proposals/{id}` → `revokeAdminRecoveryProposal` (`recovery.go:47`) | A | `RevokeRecoveryProposal` | T | retain actor/target transaction check | V11 |
|
||||
| 12 | `POST /auth/recovery-proposals/{id}/confirm` → `confirmRecoveryProposal` (`recovery.go:81`) | S | `ConfirmRecoveryProposal` | T | retain owner/session proof in transaction | V12 |
|
||||
| 13 | `POST /auth/recovery-proposals/{id}/rotate-grant` → `rotateRecoveryGrant` (`recovery.go:105`) | S | `RotateRecoveryGrant` | T | retain owner/session proof in transaction | V13 |
|
||||
| 14 | `POST /admin/users/{owner}/password` → `resetAdminUserPassword` (`accounts.go:468`) | A | 410 response only | — | no business sink | V14 |
|
||||
| 15 | `POST /admin/users/{owner}/login-keys` → `issueAdminLoginKey` (`accounts.go:478`) | A | 410 response only | — | no business sink | V15 |
|
||||
| 16 | `DELETE /admin/users/{owner}/login-keys/{id}` → `revokeAdminLoginKey` (`accounts.go:494`) | A | `RevokeLoginKeyCascade` | M | D: lock/recheck admin actor too, then target/grant | V16 |
|
||||
| 17 | `POST /projects` → `createProject` (`server.go:713`) | A | `CreateProject` or RDM `Create` + Host projection | M/E | P | V17 |
|
||||
| 18 | `PATCH /projects/{id}` → `patchProject` (`server.go:734`) | A | RDM `Rename` or `RenameProject`; `UpdateProjectSettings` | M/E | P; D for every settings write | V18 |
|
||||
| 19 | `DELETE /projects/{id}` → `deleteProject` (`server.go:812`) | A | RDM `Archive` or `DeleteProject` | M/E | P | V19 |
|
||||
| 20 | `POST /projects/{id}/conversations` → `createConversation` (`server.go:875`) | A | `CreateConversation[InProject]`; RDM `Work` precheck | M | D; keep domain work check | V20 |
|
||||
| 21 | `PATCH /conversations/{id}` → `patchConversation` (`server.go:914`) | A | update, permission, note, flags: separate Store writes | M | D at each final write | V21 |
|
||||
| 22 | `DELETE /conversations/{id}` → `deleteConversation` (`server.go:950`) | A | `DeleteConversation` | M | D | V22 |
|
||||
| 23 | `POST /conversations/{id}/messages` → `postMessage` (`server.go:956`) | A | message, run, message-run link; optional Bus dispatch | M/E | D per write; C for command; actor lock before conversation lock | V23 |
|
||||
| 24 | `POST /conversations/{id}/rewind` → `rewindFiles` (`rewind.go:27`) | A | checkpoint files, then `AppendMessage` | M/E | X for rewind, D for message | V24 |
|
||||
| 25 | `POST /conversations/{id}/regenerate` → `regenerateMessage` (`borrow.go:159`) | A | message flags/exclusion, regen marker, rewind files, new run | M/E | D per write, X for rewind | V25 |
|
||||
| 26 | `POST /messages/{id}/exclude` → `excludeMessage` (`borrow.go:122`) | A | `ExcludeRunGroup(messageID)` | M | D; derive conversation owner in transaction | V26 |
|
||||
| 27 | `POST /messages/{id}/variant` → `selectVariant` (`borrow.go:146`) | A | `SelectVariant(messageID)` | M | D; derive conversation owner in transaction | V27 |
|
||||
| 28 | `POST /secrets` → `createSecret` (`borrow.go:21`) | A | `AddSecret` | M | D | V28 |
|
||||
| 29 | `POST /secrets/{id}/activate` → `activateSecret` (`borrow.go:39`) | A | `ActivateSecret` | M | D | V29 |
|
||||
| 30 | `PATCH /secrets/{id}` → `renameSecret` (`borrow.go:43`) | A | `RenameSecret` | M | D | V30 |
|
||||
| 31 | `DELETE /secrets/{id}` → `deleteSecret` (`borrow.go:54`) | A | `DeleteSecret` | M | D | V31 |
|
||||
| 32 | `POST /context-entries` → `saveContextEntry` (`borrow.go:64`) | A | `SaveContextEntry` | M | D | V32 |
|
||||
| 33 | `DELETE /context-entries/{id}` → `deleteContextEntry` (`borrow.go:78`) | A | `DeleteContextEntry` | M | D | V33 |
|
||||
| 34 | `POST /prompt-commands` → `saveUserPromptCommand` (`borrow.go:87`) | A | `SaveUserCommand`, then process Bus register | M/E | D; X before Bus mutation | V34 |
|
||||
| 35 | `DELETE /prompt-commands/{id}` → `deleteUserPromptCommand` (`borrow.go:102`) | A | `DeleteUserCommand`, then Bus unregister | M/E | D; X before Bus mutation | V35 |
|
||||
| 36 | `POST /runs/{id}/cancel` → `cancelRun` (`server.go:1278`) | A | PG `EnqueueCommand` or local cancel handle | M/E | D for enqueue with in-tx run owner; X locally | V36 |
|
||||
| 37 | `POST /hitl/{id}/resolve` → `resolveHITL` (`server.go:1314`) | A | local HITL/card/event writes or PG `EnqueueCommand` | M | D at each Store sink, including queue | V37 |
|
||||
| 38 | `POST /cards/{id}/actions/{actionId}` → `cardAction` (`server.go:1401`) | A | Cards action or Bus confirm, then receipt message | M/E | D for card/message; C for confirmed handler | V38 |
|
||||
| 39 | `POST /cards/{id}/pin` → `cardPin` (`server.go:1453`) | A | card version write | M | D | V39 |
|
||||
| 40 | `POST /cards/{id}/dismiss` → `cardDismiss` (`server.go:1462`) | A | card version/event write | M | D | V40 |
|
||||
| 41 | `POST /cards/{id}/to_project` → `cardToProject` (`server.go:1386`) | A | project card copy and original dismissal | M | D at both writes | V41 |
|
||||
| 42 | `POST /projects/{id}/uploads` → `upload` (`server.go:1469`) | A | Blob + artifact row, card, knowledge/vector index | M/E | F; D at later card/index writes | V42 |
|
||||
| 43 | `PUT /artifacts/{id}/content` → `putArtifactContent` (`server.go:1651`) | A | **in-place Blob overwrite before SQL UPDATE** | M/E | F using new immutable blob ref, guarded DB pointer swap | V43 |
|
||||
| 44 | `DELETE /artifacts/{id}` → `deleteArtifact` (`server.go:1656`) | A | metadata, index/vector rows, Blob deletion | M/E | D for visible rows; X for post-commit GC | V44 |
|
||||
| 45 | `POST /profiles` → `saveProfile` (`server.go:1667`) | A | profile Store row | M | D | V45 |
|
||||
| 46 | `DELETE /profiles/{id}` → `deleteProfile` (`server.go:1683`) | A | profile Store row | M | D | V46 |
|
||||
| 47 | `POST /prompts` → `savePromptModule` (`server.go:2023`) | A | prompt Store row | M | D | V47 |
|
||||
| 48 | `DELETE /prompts/{id}` → `deletePromptModule` (`server.go:2044`) | A | prompt Store row | M | D | V48 |
|
||||
| 49 | `PUT /settings/user` → `putSettingsUser` (`server.go:1717`) | A | settings transaction, then separate snapshot row | M | D for settings and snapshot | V49 |
|
||||
| 50 | `PUT /settings/project/{id}` → `putSettingsProject` (`server.go:1726`) | A | settings transaction, then separate snapshot row | M | D for settings and snapshot | V50 |
|
||||
| 51 | `PUT /settings/system` → `putSettingsSystem` (`server.go:1782`) | A | system settings and snapshot under synthetic owner | M | D with **request actor**, not `OwnerSystemRow` | V51 |
|
||||
| 52 | `POST /tools` → `createUserTool` (`server.go:1815`) | A | process Registry register; future HTTP tool execution | E | X for registration and each tool execution | V52 |
|
||||
| 53 | `DELETE /tools/{name}` → `deleteUserTool` (`server.go:1834`) | A | process Registry unregister | E | X | V53 |
|
||||
| 54 | `POST /plugins/{id}/enabled` → `setPluginEnabled` (`platform_api.go:132`) | A | `SetPluginEnabled` upsert | M | D | V54 |
|
||||
| 55 | `POST /plugins/{id}/activation` → `setSurfaceActivation` (`platform_api.go:251`) | A | activation transaction, separate audit row | M | D for both writes | V55 |
|
||||
| 56 | `POST /surfaces` → `submitSurface` (`platform_api.go:194`) | A | snapshot transaction, then event/pending row | M | D for snapshot/event writes | V56 |
|
||||
| 57 | `POST /surfaces/{id}/actions` → `submitSurfaceAction` (`platform_api.go:271`) | A | action insert, Bus dispatch, action-state update | M/E | D for both action writes; C for dispatch | V57 |
|
||||
| 58 | `POST /plugins/reload` → `reloadPlugins` (`server.go:1908`) | A | process registry/sidecar changes and `UpsertPlugin` | M/E | X for process changes; D for DB writes | V58 |
|
||||
| 59 | `POST /commands/{id}` → `execCommand` (`server.go:1211`) | A | Bus audit/card/pending or arbitrary command handler/RDM | M/E | C, X for external handler | V59 |
|
||||
| 60 | `POST /projects/{id}/knowledge` → `addKnowledge` (`server.go:2055`) | A | `AddKnowledge` batch transaction | M | D; in-tx project/owner check | V60 |
|
||||
| 61 | `POST /tasks` → `createBackgroundTask` (`platform_api.go:16`) | A | task insert | M | D | V61 |
|
||||
| 62 | `POST /tasks/{id}` → `updateBackgroundTask` (`platform_api.go:52`) | A | task update | M | D | V62 |
|
||||
| 63 | `POST /memory` → `putMemory` (`platform_api.go:69`) | A | memory upsert | M | D | V63 |
|
||||
| 64 | `POST /audit` → `postAudit` (`platform_api.go:112`) | A | client-origin business audit row | M | D; security-denial audit is a separate exception | V64 |
|
||||
| 65 | `POST /admin/backup` → `backup` (`server.go:2224`) | A | directory + SQLite backup file | E | X and explicit admin authorization | V65 |
|
||||
| 66 | `POST /admin/recovery-holds/{runId}/release` → `releaseRecoveryHold` (`accounts.go:526`) | A | recovery-hold UPDATE | M | D with actor admin check and run/hold owner lookup | V66 |
|
||||
|
||||
The route count is not a full write count. `GET /api/llm/budget` → `llmBudget` (`platform_api.go`) calls `AccountTokens(owner, 0, budget)`; the amended Store returns a read-only total when tokens are zero. **V67** therefore has no business write. Ordinary authenticated GETs can refresh session `last_seen_at` as credential maintenance; session/API-key in-flight revocation remains a separate impact analysis.
|
||||
|
||||
## Host and run manager exits
|
||||
|
||||
| ID | Entry and actor | Final sink | Now → required fence | Verify |
|
||||
|---|---|---|---|---|
|
||||
| H01 | `inprocHost.KVPut`, host owner (`hostcaps.go:295`) | `PutPluginKV` | M → D | VH01 |
|
||||
| H02 | `inprocHost.Audit`, host owner; actor label is `plugin:<id>` (`hostcaps.go:309`) | `AppendAuditEvent` | M → D using host owner | VH02 |
|
||||
| H03 | `KnowledgeWrite/Publish/Delete`, host owner (`hostcaps.go:350-424`) | knowledge rows, vector rows; publish deletes then writes | M/E → D at every DB sink; X before embedding/remote model | VH03 |
|
||||
| H04 | `TaskCreate/Update`, host owner (`hostcaps.go:427-443`) | background-task rows | M → D | VH04 |
|
||||
| H05 | `EnsureProject/RenameOwnedProject`, host owner (`project_lifecycle.go:125-135`) | Backend projection rows, RDM callback upstream | M/E → actor D before project-name lock; X at callback | VH05 |
|
||||
| H06 | `SurfaceSave`, host owner (`hostcaps.go:481`) | `surfacesvc.Submit` snapshot/event | M → D at Store sinks | VH06 |
|
||||
| H07 | `Complete`, host owner (`hostcaps.go:319`) | external LLM request | E → X with context cancellation | VH07 |
|
||||
| H08 | scoped Host reconstructed from owner/plugin ID (`hostcaps.go:103`) | same H01-H07 methods | M/E → same final sink fences; early `requireEffective` alone is insufficient | VH08 |
|
||||
| G01 | `startClaimedRun`/subagent spawn, run owner (`runmgr.go:1443,1924`) | `CreateRun`, `UpdateRun` and admission rows | M → D for business state; keep failure-only cleanup explicit | VG01 |
|
||||
| G02 | `Emit`, `AskHITL`, cards, plan/todos (`runmgr.go:676-935`) | events, HITL/card, plan/todo rows | M → D at each final Store write | VG02 |
|
||||
| G03 | `WriteArtifact`, run owner (`runmgr.go:968`) | Blob, artifact, event, knowledge/vector rows | M/E → F then D for each later write | VG03 |
|
||||
| G04 | `ExecuteTool`, run owner (`runmgr.go:1080-1167`) | Registry HTTP/command/filesystem side effects, output artifacts | E/M → X before external call; C/D for command and output sinks | VG04 |
|
||||
| G05 | `stageInputs`, checkpoint recording/rewind and subagent transcript (`inputs.go:17-90`, `rewind.go:37-99`, `subagent_transcript.go:46-84`) | workspace files | E → X before each effect and cancellation; no cross-system atomic claim | VG05 |
|
||||
| G06 | `ResolveHITL`, run/card owner (`runmgr.go:1780-1860`) | HITL/card/event and possible run writes | M → D | VG06 |
|
||||
| G07 | background notice delivery and subagent completion (`runmgr.go:1982-2041,2444-2455`) | run/message/event plus transcript files | M/E → D for business rows, X for files | VG07 |
|
||||
| G08 | `settleRunWithFence`, run owner (`runmgr.go:1700-1722`) | final run + assistant message | T for enabled check in `store/recovery.go:184-220`; retain narrow failure-state exception | VG08 |
|
||||
| G09 | `OnUsage`, run owner (`app.go:668-680`) | usage and budget audit rows | M → D or documented accounting-only exception | VG09 |
|
||||
|
||||
At the 4053c4d baseline, Host/runmgr effective-account queries were early checks, Bus confirmation and Surface writes had separate sinks, and artifact content could be overwritten in place before a checked SQL update. These are the reasons for the sink-level mapping below; file paths and old line numbers in the historical tables are navigational rather than final-SHA evidence.
|
||||
|
||||
## Amended candidate mapping and limits
|
||||
|
||||
This section supersedes the historical `Now` column when checking the amended candidate. V01–V15 retain their existing account/credential transaction or response-only behavior; V16 uses actor-checked login-key revocation. V17–V23 route project, conversation, message, run creation and link writes through account-checked Store entries. V24–V35 use guarded file/message and borrow/secret/context/command entries; V25 prepares the message exclusion and regeneration marker in one checked transaction, then consumes the marker only with successful run settlement. V36–V44 guard command queue, HITL, card, surface/event, artifact metadata and index writes. Artifact content updates switch immutable blob references in the checked transaction. V45–V51 guard profile, prompt, settings and snapshot writes, using the **request actor** for system settings. V52–V59 combine last checks for process/HTTP/tool effects with guarded plugin, surface, card, command pending and audit DB writes. V60–V66 use guarded knowledge, task, memory, audit and hold updates. V65 refuses whole-database backup under authenticated mode because administrator status does not grant access to other owners' business content; auth-off backup has a last check. V67 is read-only at zero tokens.
|
||||
|
||||
VH01–VH04/VH06/VH08 use the Host owner and final Store account checks for Backend-owned rows. H05 delegated rename now derives a **single-project** projection from the current authenticated lifecycle or command Host; its final Store transaction locks the real operator and creator in stable order, checks both effective states and verifies the persisted project owner. The global `NewScopedHost` cannot derive this delegated capability; its original creator-owned methods remain. VH07 checks immediately before its external model call and passes the caller context. VG01–VG09 use checked run, admission/lease, event, HITL, card, artifact, index, notice, command and settle sinks. File staging, transcript, tool HTTP/search/code execution and overlay writeback check just before effects and propagate cancellation. Failed/cancelled/interrupted run sealing, lease release, notice deletion and failed-start marker deletion are narrowly scoped safety cleanup; positive token usage is accounting for model work already incurred.
|
||||
|
||||
The H05/V17–V19 late-write blocker was reproduced in the earlier candidate and repaired under the separately approved Documents `5783940` plan. Tests cover creator/operator effective state, a disable between early check and final write on SQLite/PG, a two-process PG disable, opposite lock order progress, and both HTTP PATCH and no-conversation command Host sources. The RDM database authorization/intent commit is still separate from Backend's account/project transaction; a permission withdrawal or cross-database failure is handled by the existing I1 failure intent and recovery path rather than a zero-window assertion. Completion and stage 6 claims require exact-HEAD receipts and human review.
|
||||
|
||||
The account lock closes the **account-enabled versus Backend-owned business write** race on the same database. It does not prove that a session or API key revoked after request authentication cannot complete a write while that account remains enabled; that is a distinct credential-version fence and outside this amendment's promise. RDM commits in its own database and external requests, processes and files cannot share the Backend transaction. The last check can race with those effects, and a multi-file operation can leave earlier files committed when a later check fails. No cross-database or external zero-window claim is made.
|
||||
|
||||
**Sink discovery still required:** Backend source alone cannot enumerate every dynamically registered Bus handler, plugin sidecar command, or the RDM callback's independent database writes. V17–V19, V23, V38, V57–V59 and VH05 must record the actual composed handlers and their final sinks in the tested composition; an unknown or unguarded handler blocks the A1 completion claim. The external HTTP tool destination and filesystem actions depend on runtime configuration, so VG04/VG05 must verify the actual enabled tool set and cancellation points.
|
||||
|
||||
## Common implementation and verification gate
|
||||
|
||||
1. Build one short Store write-transaction primitive: `Begin` → PG actor advisory lock (`account:<owner>`) → `SELECT enabled ... FOR UPDATE` → existing resource/scope/conversation/admission locks → resource authorization and write → commit. The actor lock must precede all other PG locks, including project-name locks; participating account owners use sorted order, then bootstrap lock where required. `SetAccountEnabled` already takes the account lock (`store/account_state.go:20-45`); `lockAccountUsers` supplies sorted PG account locking (`store/account_model.go:115-145`). SQLite uses `_txlock=immediate` (`store/store.go:188-199`). In auth-off mode, only a missing `OwnerDefault` row is implicitly enabled; an existing disabled row rejects (`app/account_guard.go:10-23`). No DB connection or transaction spans request body decoding, embedding, tool execution, RDM callback, or full HTTP lifetime.
|
||||
2. Make methods with only resource IDs derive owner inside the guarded transaction. Pass actor separately for system settings (`OwnerSystemRow`), delegated Host calls, commands, queue commands, audit and background jobs. Preserve project/duty authorization as a separate condition; account enabled does not grant resource access. For files, stage invisible immutable bytes and switch the visible DB reference in `D`; failed transaction deletes staged bytes, post-commit old-blob GC may continue. RDM's independent database keeps the explicit cross-database window from the amendment.
|
||||
3. Review **every** V/VH/VG route against its final sink on the fixed candidate SHA. Use deterministic pause-and-disable tests for representative project, message/run, artifact, settings, command/surface and Host/runmgr paths on SQLite and isolated PostgreSQL. Test account lock ordering with two PG connections. Verify the existing credential routes' in-transaction ordering. For external effects, verify the last check and cancellation and state the remaining check-to-effect window. Record exact command, tested SHA and output in the Dev task card; local SELF is not independent REPLAY or CI GATE.
|
||||
|
||||
Stage 6 stops if any Backend-owned business write lacks a final actor-enabled transaction fence, if artifact content remains visibly mutable after disable via in-place overwrite, or if an external/RDM effect is described as same-DB atomic. Session/API-key in-flight revocation is a separate amendment impact analysis; enabled checks do not prove credential-revocation fencing.
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ab.go — 任务级 A/B 双跑 harness(Q3 WP5):同一批任务分别经两个入口
|
||||
// (direct 对话循环 / squad 编译图)执行,逐任务记录差异(不要求字节级
|
||||
// 相同),汇总为两份 EntryScore 交 DecideSwitch 裁决默认入口切换。
|
||||
// 本包保持纯机制(不依赖 runtime/squad);两侧 RunFunc 由调用方装配。
|
||||
|
||||
// ABTask 是对照批中的一个任务。
|
||||
type ABTask struct {
|
||||
ID string
|
||||
Goal string
|
||||
// Themes 期望产出要点(短词,非整句照抄):打分用 MissingThemes,
|
||||
// 差异记录用要点覆盖对比。
|
||||
Themes []string
|
||||
}
|
||||
|
||||
// ABOutcome 是一个入口对一个任务的执行结果(调用方 RunFunc 填写)。
|
||||
type ABOutcome struct {
|
||||
// Completed 任务是否正常收口(失败/取消 = false)。
|
||||
Completed bool
|
||||
// Summary 交付摘要(要点覆盖打分的对象;调用方可拼接工件正文)。
|
||||
Summary string
|
||||
// FirstToken 首字延迟(入口收到任务到首个可见输出)。
|
||||
FirstToken time.Duration
|
||||
// Err 失败原因(Completed=false 时填写)。
|
||||
Err string
|
||||
}
|
||||
|
||||
// RunFunc 是一个入口的执行侧(direct 或 squad 编译图)。
|
||||
type RunFunc func(ctx context.Context, task ABTask) (ABOutcome, error)
|
||||
|
||||
// ABTaskRecord 是一个任务的双跑对照记录(差异记录单位)。
|
||||
type ABTaskRecord struct {
|
||||
Task ABTask
|
||||
Direct ABOutcome
|
||||
Squad ABOutcome
|
||||
// Diffs 差异注记(状态 / 要点覆盖 / 首字延迟),空 = 两侧一致。
|
||||
Diffs []string
|
||||
// DirectPoints / SquadPoints 单任务得分(满分 100:完成 + 要点全覆盖;
|
||||
// 未完成 0 分;完成但缺要点按缺失比例扣)。
|
||||
DirectPoints int
|
||||
SquadPoints int
|
||||
}
|
||||
|
||||
// ABReport 是一批双跑的对照报告(切换裁决的证据形态)。
|
||||
type ABReport struct {
|
||||
Records []ABTaskRecord
|
||||
Direct EntryScore
|
||||
Squad EntryScore
|
||||
Decision SwitchDecision
|
||||
// Text 是可附在切换 PR / 任务卡上的短报告(Report(Decision) + 差异清单)。
|
||||
Text string
|
||||
}
|
||||
|
||||
// RunAB 双跑同一批任务并汇总对照报告。任一侧 RunFunc 返回 error 视为该侧
|
||||
// 该任务失败(记录进 outcome.Err),不中断整批(对照要的是全批差异面)。
|
||||
func RunAB(ctx context.Context, tasks []ABTask, direct, squad RunFunc) ABReport {
|
||||
rep := ABReport{}
|
||||
if len(tasks) == 0 {
|
||||
rep.Decision = DecideSwitch(nil, nil)
|
||||
rep.Text = Report(rep.Decision)
|
||||
return rep
|
||||
}
|
||||
var directFT, squadFT []time.Duration
|
||||
var directSum, squadSum int
|
||||
for _, task := range tasks {
|
||||
rec := ABTaskRecord{Task: task}
|
||||
rec.Direct = runOne(ctx, task, direct)
|
||||
rec.Squad = runOne(ctx, task, squad)
|
||||
rec.DirectPoints = scoreOutcome(rec.Direct, task.Themes)
|
||||
rec.SquadPoints = scoreOutcome(rec.Squad, task.Themes)
|
||||
rec.Diffs = diffRecord(rec)
|
||||
rep.Records = append(rep.Records, rec)
|
||||
directSum += rec.DirectPoints
|
||||
squadSum += rec.SquadPoints
|
||||
directFT = append(directFT, rec.Direct.FirstToken)
|
||||
squadFT = append(squadFT, rec.Squad.FirstToken)
|
||||
}
|
||||
rep.Direct = EntryScore{Name: "direct", Score: directSum / len(tasks), FirstTokenP50: p50(directFT)}
|
||||
rep.Squad = EntryScore{Name: "squad", Score: squadSum / len(tasks), FirstTokenP50: p50(squadFT)}
|
||||
rep.Decision = DecideSwitch(&rep.Direct, &rep.Squad)
|
||||
rep.Text = renderReport(rep)
|
||||
return rep
|
||||
}
|
||||
|
||||
func runOne(ctx context.Context, task ABTask, fn RunFunc) ABOutcome {
|
||||
if fn == nil {
|
||||
return ABOutcome{Err: "入口未装配"}
|
||||
}
|
||||
out, err := fn(ctx, task)
|
||||
if err != nil {
|
||||
out.Completed = false
|
||||
if out.Err == "" {
|
||||
out.Err = err.Error()
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// scoreOutcome 单任务打分:未完成 0;完成 = 100 - 缺失要点比例 × 100。
|
||||
func scoreOutcome(out ABOutcome, themes []string) int {
|
||||
if !out.Completed {
|
||||
return 0
|
||||
}
|
||||
if len(themes) == 0 {
|
||||
return 100
|
||||
}
|
||||
missing := MissingThemes(out.Summary, themes)
|
||||
return 100 - len(missing)*100/len(themes)
|
||||
}
|
||||
|
||||
// diffRecord 逐任务差异注记(状态 / 要点 / 首字延迟;不要求字节级相同)。
|
||||
func diffRecord(rec ABTaskRecord) []string {
|
||||
var diffs []string
|
||||
if rec.Direct.Completed != rec.Squad.Completed {
|
||||
diffs = append(diffs, fmt.Sprintf("状态:direct=%v squad=%v(%s / %s)",
|
||||
rec.Direct.Completed, rec.Squad.Completed, rec.Direct.Err, rec.Squad.Err))
|
||||
}
|
||||
if len(rec.Task.Themes) > 0 {
|
||||
dm := MissingThemes(rec.Direct.Summary, rec.Task.Themes)
|
||||
sm := MissingThemes(rec.Squad.Summary, rec.Task.Themes)
|
||||
if strings.Join(dm, ",") != strings.Join(sm, ",") {
|
||||
diffs = append(diffs, fmt.Sprintf("要点缺失:direct=%v squad=%v", dm, sm))
|
||||
}
|
||||
}
|
||||
gap := rec.Squad.FirstToken - rec.Direct.FirstToken
|
||||
if gap < 0 {
|
||||
gap = -gap
|
||||
}
|
||||
if gap > time.Second {
|
||||
diffs = append(diffs, fmt.Sprintf("首字延迟差:%s(direct=%s squad=%s)",
|
||||
gap, rec.Direct.FirstToken, rec.Squad.FirstToken))
|
||||
}
|
||||
if rec.Direct.Completed && rec.Squad.Completed &&
|
||||
strings.TrimSpace(rec.Direct.Summary) != strings.TrimSpace(rec.Squad.Summary) {
|
||||
diffs = append(diffs, "摘要文本不同(记录在案,不要求字节级相同)")
|
||||
}
|
||||
return diffs
|
||||
}
|
||||
|
||||
func p50(ds []time.Duration) time.Duration {
|
||||
if len(ds) == 0 {
|
||||
return 0
|
||||
}
|
||||
sorted := append([]time.Duration(nil), ds...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
|
||||
return sorted[len(sorted)/2]
|
||||
}
|
||||
|
||||
// renderReport 报告全文:裁决 + 分数 + 逐任务差异清单。
|
||||
func renderReport(rep ABReport) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(Report(rep.Decision))
|
||||
fmt.Fprintf(&b, "\n分数:direct=%d squad=%d;首字P50:direct=%s squad=%s\n",
|
||||
rep.Direct.Score, rep.Squad.Score, rep.Direct.FirstTokenP50, rep.Squad.FirstTokenP50)
|
||||
for _, rec := range rep.Records {
|
||||
fmt.Fprintf(&b, "- %s:direct %d 分 / squad %d 分", rec.Task.ID, rec.DirectPoints, rec.SquadPoints)
|
||||
if len(rec.Diffs) == 0 {
|
||||
b.WriteString("(无差异注记)")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
for _, d := range rec.Diffs {
|
||||
fmt.Fprintf(&b, " - %s\n", d)
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ab_test.go — Q3 WP5:A/B 双跑 harness 机制面(差异记录 / 打分 / P50 /
|
||||
// DecideSwitch 联动)。真实入口的集成对照在 internal/wfcompile 的
|
||||
// demo.brief 双跑测试。
|
||||
|
||||
func stubRun(completed bool, summary string, ft time.Duration, errText string) RunFunc {
|
||||
return func(context.Context, ABTask) (ABOutcome, error) {
|
||||
return ABOutcome{Completed: completed, Summary: summary, FirstToken: ft, Err: errText}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunABRecordsDiffsAndScores(t *testing.T) {
|
||||
tasks := []ABTask{
|
||||
{ID: "t1", Goal: "写简报", Themes: []string{"太空电梯", "核对标记"}},
|
||||
{ID: "t2", Goal: "查资料", Themes: []string{"三来源"}},
|
||||
}
|
||||
direct := func(_ context.Context, task ABTask) (ABOutcome, error) {
|
||||
switch task.ID {
|
||||
case "t1":
|
||||
return ABOutcome{Completed: true, Summary: "关于太空电梯的简报,核对标记:P4", FirstToken: 100 * time.Millisecond}, nil
|
||||
default:
|
||||
return ABOutcome{Completed: true, Summary: "对照完成,三来源齐", FirstToken: 200 * time.Millisecond}, nil
|
||||
}
|
||||
}
|
||||
squad := func(_ context.Context, task ABTask) (ABOutcome, error) {
|
||||
switch task.ID {
|
||||
case "t1":
|
||||
// 完成但缺一个要点 → 扣分 + 差异注记。
|
||||
return ABOutcome{Completed: true, Summary: "关于太空电梯的简报", FirstToken: 100 * time.Millisecond}, nil
|
||||
default:
|
||||
return ABOutcome{Completed: false, Err: "编译图步骤失败", FirstToken: 0}, nil
|
||||
}
|
||||
}
|
||||
rep := RunAB(context.Background(), tasks, direct, squad)
|
||||
if len(rep.Records) != 2 {
|
||||
t.Fatalf("records = %d", len(rep.Records))
|
||||
}
|
||||
r1 := rep.Records[0]
|
||||
if r1.DirectPoints != 100 || r1.SquadPoints != 50 {
|
||||
t.Fatalf("t1 points = %d/%d", r1.DirectPoints, r1.SquadPoints)
|
||||
}
|
||||
if !strings.Contains(strings.Join(r1.Diffs, ";"), "要点缺失") {
|
||||
t.Fatalf("t1 diffs = %v", r1.Diffs)
|
||||
}
|
||||
r2 := rep.Records[1]
|
||||
if r2.SquadPoints != 0 || !strings.Contains(strings.Join(r2.Diffs, ";"), "状态") {
|
||||
t.Fatalf("t2 = %d %v", r2.SquadPoints, r2.Diffs)
|
||||
}
|
||||
// 分数汇总:direct 100,squad (50+0)/2=25 → 不达标,保持 direct。
|
||||
if rep.Direct.Score != 100 || rep.Squad.Score != 25 {
|
||||
t.Fatalf("scores = %d/%d", rep.Direct.Score, rep.Squad.Score)
|
||||
}
|
||||
if rep.Decision.Met || !strings.Contains(rep.Decision.Reason, "低于") {
|
||||
t.Fatalf("decision = %+v", rep.Decision)
|
||||
}
|
||||
if !strings.Contains(rep.Text, "保持 direct") {
|
||||
t.Fatalf("report = %s", rep.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunABSwitchMetWhenEqual(t *testing.T) {
|
||||
tasks := []ABTask{{ID: "t1", Goal: "g", Themes: []string{"要点"}}}
|
||||
fn := stubRun(true, "覆盖要点的产出", 50*time.Millisecond, "")
|
||||
rep := RunAB(context.Background(), tasks, fn, fn)
|
||||
if rep.Direct.Score != 100 || rep.Squad.Score != 100 {
|
||||
t.Fatalf("scores = %d/%d", rep.Direct.Score, rep.Squad.Score)
|
||||
}
|
||||
if !rep.Decision.Met {
|
||||
t.Fatalf("同分同延迟应达标:%+v", rep.Decision)
|
||||
}
|
||||
if len(rep.Records[0].Diffs) != 0 {
|
||||
t.Fatalf("同产出应无差异注记:%v", rep.Records[0].Diffs)
|
||||
}
|
||||
if rep.Direct.FirstTokenP50 != 50*time.Millisecond {
|
||||
t.Fatalf("p50 = %s", rep.Direct.FirstTokenP50)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunABLatencyGate(t *testing.T) {
|
||||
tasks := []ABTask{{ID: "t1", Goal: "g"}}
|
||||
direct := stubRun(true, "ok", 100*time.Millisecond, "")
|
||||
squad := stubRun(true, "ok", 4*time.Second, "") // 额外首字 > 3s
|
||||
rep := RunAB(context.Background(), tasks, direct, squad)
|
||||
if rep.Decision.Met || !strings.Contains(rep.Decision.Reason, "超过 3 秒") {
|
||||
t.Fatalf("decision = %+v", rep.Decision)
|
||||
}
|
||||
if !strings.Contains(strings.Join(rep.Records[0].Diffs, ";"), "首字延迟差") {
|
||||
t.Fatalf("diffs = %v", rep.Records[0].Diffs)
|
||||
}
|
||||
}
|
||||
|
||||
// RunFunc 返回 error:记为该侧失败,不中断整批。
|
||||
func TestRunABToleratesRunFuncError(t *testing.T) {
|
||||
tasks := []ABTask{{ID: "t1"}, {ID: "t2"}}
|
||||
direct := stubRun(true, "ok", 0, "")
|
||||
squad := func(_ context.Context, task ABTask) (ABOutcome, error) {
|
||||
if task.ID == "t1" {
|
||||
return ABOutcome{}, context.DeadlineExceeded
|
||||
}
|
||||
return ABOutcome{Completed: true, Summary: "ok"}, nil
|
||||
}
|
||||
rep := RunAB(context.Background(), tasks, direct, squad)
|
||||
if rep.Records[0].SquadPoints != 0 || rep.Records[0].Squad.Err == "" {
|
||||
t.Fatalf("t1 = %+v", rep.Records[0].Squad)
|
||||
}
|
||||
if rep.Records[1].SquadPoints != 100 {
|
||||
t.Fatalf("t2 应不受 t1 失败影响:%+v", rep.Records[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunABEmptyBatch(t *testing.T) {
|
||||
rep := RunAB(context.Background(), nil, nil, nil)
|
||||
if rep.Decision.Met {
|
||||
t.Fatal("空批不得达标(无实测分数)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
//go:build live
|
||||
|
||||
package live
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/runtime"
|
||||
)
|
||||
|
||||
// upload 上传一个材料文件,返回 artifact id。
|
||||
func (c *client) upload(t *testing.T, projectID, name string, content []byte) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
fw, err := w.CreateFormFile("file", name)
|
||||
if err != nil {
|
||||
t.Fatalf("multipart field: %v", err)
|
||||
}
|
||||
if _, err := fw.Write(content); err != nil {
|
||||
t.Fatalf("multipart write: %v", err)
|
||||
}
|
||||
w.Close()
|
||||
req, err := http.NewRequest("POST", c.base+"/api/projects/"+projectID+"/uploads", &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("upload request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
t.Fatalf("upload %s: %s: %s", name, resp.Status, truncate(string(raw), 300))
|
||||
}
|
||||
var out struct {
|
||||
ID string `json:"artifactId"`
|
||||
LegacyID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("upload response: %v %s", err, truncate(string(raw), 300))
|
||||
}
|
||||
if out.ID == "" {
|
||||
out.ID = out.LegacyID
|
||||
}
|
||||
if out.ID == "" {
|
||||
t.Fatalf("upload response missing artifact id: %s", truncate(string(raw), 300))
|
||||
}
|
||||
return out.ID
|
||||
}
|
||||
|
||||
// runTurn 发一轮消息并等到 run 终态;期间自动应答 HITL 卡。
|
||||
func runTurn(t *testing.T, c *client, projectID, convID, text string, attachmentIDs []string, turnTimeout time.Duration) turnResult {
|
||||
t.Helper()
|
||||
started := time.Now()
|
||||
var out struct {
|
||||
Message message `json:"message"`
|
||||
Run run `json:"run"`
|
||||
}
|
||||
body := map[string]any{"text": text, "requestId": fmt.Sprintf("eval-%d", time.Now().UnixNano())}
|
||||
if len(attachmentIDs) > 0 {
|
||||
body["attachmentIds"] = attachmentIDs
|
||||
}
|
||||
if err := c.do("POST", "/api/conversations/"+convID+"/messages", body, &out); err != nil {
|
||||
t.Fatalf("post message: %v", err)
|
||||
}
|
||||
runID := out.Run.ID
|
||||
deadline := time.Now().Add(turnTimeout)
|
||||
var cur run
|
||||
for {
|
||||
var resp struct {
|
||||
Run run `json:"run"`
|
||||
}
|
||||
if err := c.do("GET", "/api/runs/"+runID, nil, &resp); err != nil {
|
||||
t.Fatalf("get run: %v", err)
|
||||
}
|
||||
cur = resp.Run
|
||||
if isTerminal(cur.Status) {
|
||||
break
|
||||
}
|
||||
autoResolveHITL(t, c, convID)
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("run %s timed out after %s (status=%s)", runID, turnTimeout, cur.Status)
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
detail := struct {
|
||||
Messages []message `json:"messages"`
|
||||
ActiveHitl []hitlCard `json:"activeHitl"`
|
||||
}{}
|
||||
if err := c.do("GET", "/api/conversations/"+convID, nil, &detail); err != nil {
|
||||
t.Fatalf("conversation detail: %v", err)
|
||||
}
|
||||
var assistant *message
|
||||
for i := range detail.Messages {
|
||||
m := detail.Messages[i]
|
||||
if m.RunID == runID && m.Role == "assistant" && !m.Excluded {
|
||||
assistant = &detail.Messages[i]
|
||||
}
|
||||
}
|
||||
tr := turnResult{User: text, RunID: runID, Status: cur.Status, DurationMs: time.Since(started).Milliseconds()}
|
||||
if cur.Usage != nil {
|
||||
tr.Usage = &cur
|
||||
}
|
||||
if assistant == nil {
|
||||
tr.Issues = append(tr.Issues, "没有找到本回合的助手消息")
|
||||
return tr
|
||||
}
|
||||
collectMetrics(&tr, assistant)
|
||||
// 工件清单:本 run 产出的。
|
||||
var arts []artifactMeta
|
||||
if err := c.do("GET", "/api/projects/"+projectID+"/artifacts", nil, &arts); err == nil {
|
||||
for _, a := range arts {
|
||||
if a.SourceRun == runID {
|
||||
tr.Artifacts = append(tr.Artifacts, a.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if cur.Status != "completed" {
|
||||
tr.Issues = append(tr.Issues, "run 终态="+cur.Status)
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
func isTerminal(status string) bool {
|
||||
switch status {
|
||||
case "completed", "failed", "cancelled":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// autoResolveHITL 无人值守:有活动确认卡时选第一个选项(没有选项给自由文本同意)。
|
||||
func autoResolveHITL(t *testing.T, c *client, convID string) {
|
||||
t.Helper()
|
||||
detail := struct {
|
||||
ActiveHitl []hitlCard `json:"activeHitl"`
|
||||
}{}
|
||||
if err := c.do("GET", "/api/conversations/"+convID, nil, &detail); err != nil {
|
||||
return
|
||||
}
|
||||
for _, card := range detail.ActiveHitl {
|
||||
resp := map[string]string{}
|
||||
if len(card.Choices) > 0 {
|
||||
resp["choiceId"] = card.Choices[0].ID
|
||||
} else if card.AllowFreeInput {
|
||||
resp["freeText"] = "同意继续(评测自动应答)"
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
_ = c.do("POST", "/api/hitl/"+card.ID+"/resolve", resp, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func collectMetrics(tr *turnResult, m *message) {
|
||||
for _, p := range m.Parts {
|
||||
switch p.Type {
|
||||
case "text":
|
||||
if !p.Process && strings.TrimSpace(p.Text) != "" {
|
||||
tr.FinalText = p.Text
|
||||
}
|
||||
case "tool_call":
|
||||
if p.ToolCall == nil {
|
||||
continue
|
||||
}
|
||||
tr.Metrics.ToolCalls++
|
||||
args := p.ToolCall.Arguments
|
||||
switch p.ToolCall.Name {
|
||||
case "update_plan", "update_todos":
|
||||
tr.Metrics.PlanUpdates++
|
||||
case "run_python":
|
||||
if strings.Contains(args, "new_document(") || strings.Contains(args, "new_presentation(") || strings.Contains(args, "new_workbook(") {
|
||||
tr.Metrics.FullRegens++
|
||||
}
|
||||
}
|
||||
if strings.Contains(args, "dir(") || strings.Contains(args, "inspect.signature") || strings.Contains(args, "help(") {
|
||||
tr.Metrics.APIDiscovery++
|
||||
}
|
||||
if strings.Contains(args, "inputs/") {
|
||||
tr.Metrics.ReadsOriginal++
|
||||
}
|
||||
case "tool_result":
|
||||
if p.ToolResult == nil {
|
||||
continue
|
||||
}
|
||||
s := p.ToolResult.Summary
|
||||
if p.ToolResult.Status == "error" || strings.HasPrefix(s, "exit=1") || strings.Contains(s, "Traceback") || strings.Contains(s, "ModuleNotFoundError") {
|
||||
tr.Metrics.ToolFailures++
|
||||
}
|
||||
case "check":
|
||||
if p.Check == nil {
|
||||
continue
|
||||
}
|
||||
tr.Metrics.VerifierChecks++
|
||||
tr.Checks = append(tr.Checks, checkRecord{Passed: p.Check.Passed, Reason: p.Check.Reason})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lastArtifactTurn(tr taskResult) *turnResult {
|
||||
for i := len(tr.Turns) - 1; i >= 0; i-- {
|
||||
if len(tr.Turns[i].Artifacts) > 0 {
|
||||
return &tr.Turns[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadArtifactText(t *testing.T, c *client, projectID, name string) string {
|
||||
t.Helper()
|
||||
var arts []artifactMeta
|
||||
if err := c.do("GET", "/api/projects/"+projectID+"/artifacts", nil, &arts); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, a := range arts {
|
||||
if a.Name != name {
|
||||
continue
|
||||
}
|
||||
req, _ := http.NewRequest("GET", c.base+"/api/artifacts/"+a.ID+"/content", nil)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if strings.HasSuffix(strings.ToLower(a.Name), ".docx") {
|
||||
return docxTextIfDocx(t, a.Name, raw)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---- 文本抽取与事实核查 ----
|
||||
|
||||
// docxTextIfDocx:docx 抽纯文本(标题行带 # 前缀);其他类型原样返回。
|
||||
func docxTextIfDocx(t *testing.T, name string, raw []byte) string {
|
||||
t.Helper()
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".docx") {
|
||||
return string(raw)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if f.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer rc.Close()
|
||||
dec := xml.NewDecoder(rc)
|
||||
var b strings.Builder
|
||||
var para strings.Builder
|
||||
style, inPara := "", false
|
||||
flush := func() {
|
||||
text := strings.TrimSpace(para.String())
|
||||
if text != "" {
|
||||
if lvl := docxHeadingLevel(style); lvl > 0 {
|
||||
b.WriteString(strings.Repeat("#", lvl) + " ")
|
||||
}
|
||||
b.WriteString(text + "\n")
|
||||
}
|
||||
para.Reset()
|
||||
style = ""
|
||||
}
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
switch el := tok.(type) {
|
||||
case xml.StartElement:
|
||||
switch el.Name.Local {
|
||||
case "p":
|
||||
inPara, style = true, ""
|
||||
para.Reset()
|
||||
case "pStyle":
|
||||
if inPara {
|
||||
for _, at := range el.Attr {
|
||||
if at.Name.Local == "val" {
|
||||
style = at.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case xml.EndElement:
|
||||
if el.Name.Local == "p" {
|
||||
inPara = false
|
||||
flush()
|
||||
}
|
||||
case xml.CharData:
|
||||
if inPara {
|
||||
para.Write(bytes.TrimSpace(el))
|
||||
}
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return b.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func docxHeadingLevel(style string) int {
|
||||
switch {
|
||||
case style == "Title":
|
||||
return 1
|
||||
case style == "Subtitle":
|
||||
return 2
|
||||
case strings.HasPrefix(style, "Heading"):
|
||||
n := 0
|
||||
for _, ch := range style[len("Heading"):] {
|
||||
if ch < '0' || ch > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int(ch-'0')
|
||||
}
|
||||
if n >= 1 && n <= 9 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var (
|
||||
pctRe = regexp.MustCompile(`\d+(?:\.\d+)?%`)
|
||||
dateRe = regexp.MustCompile(`\d{1,2}月\d{1,2}日`)
|
||||
bigNumRe = regexp.MustCompile(`\d{4,}`)
|
||||
claimRe = regexp.MustCompile(`(?:^|\n|\s)([一二三四五六七八九十]{1,2}、[^\s,。;|]{2,24})`)
|
||||
)
|
||||
|
||||
// unverifiedFacts 抽取交付物里的百分比/日期/大数字,逐个回材料原文找出处。
|
||||
// 找不到的列为未核实事实(模型编造或来自模型自带的「常识」)。
|
||||
func unverifiedFacts(artifactText, materialText string) []string {
|
||||
if strings.TrimSpace(artifactText) == "" || strings.TrimSpace(materialText) == "" {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
check := func(tok string) {
|
||||
tok = strings.TrimSpace(tok)
|
||||
if tok == "" || seen[tok] {
|
||||
return
|
||||
}
|
||||
seen[tok] = true
|
||||
if !strings.Contains(materialText, tok) {
|
||||
out = append(out, tok)
|
||||
}
|
||||
}
|
||||
for _, m := range pctRe.FindAllString(artifactText, -1) {
|
||||
check(m)
|
||||
}
|
||||
for _, m := range dateRe.FindAllString(artifactText, -1) {
|
||||
check(m)
|
||||
}
|
||||
for _, m := range bigNumRe.FindAllString(artifactText, -1) {
|
||||
if len(m) >= 5 { // 4 位数大量是年份/编号,只报 5 位以上
|
||||
check(m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// unmarkedFacts 从未核实清单里剔除已按「目标/预计/假设」标注的(与后端
|
||||
// runtime.MarkedAsEstimate 同一口径,标注词邻域 ±32 字符)。
|
||||
func unmarkedFacts(tokens []string, artifactText string) []string {
|
||||
var out []string
|
||||
for _, tok := range tokens {
|
||||
if !runtime.MarkedAsEstimate(artifactText, tok) {
|
||||
out = append(out, tok)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// finalVsArtifact 终轮回复里声称的章节(「一、…」)必须能在工件里找到。
|
||||
func finalVsArtifact(turn *turnResult, artifactText string) []string {
|
||||
if turn == nil || turn.FinalText == "" || strings.TrimSpace(artifactText) == "" {
|
||||
return nil
|
||||
}
|
||||
final := turn.FinalText
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, m := range claimRe.FindAllStringSubmatch(final, -1) {
|
||||
claim := strings.TrimSpace(m[1])
|
||||
if claim == "" || seen[claim] {
|
||||
continue
|
||||
}
|
||||
seen[claim] = true
|
||||
if !strings.Contains(artifactText, claim) {
|
||||
out = append(out, claim)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- 报告 ----
|
||||
|
||||
func writeReport(t *testing.T, rep *report) {
|
||||
t.Helper()
|
||||
outPath := os.Getenv("AGENT_EVAL_OUT")
|
||||
if outPath == "" {
|
||||
outPath = filepath.Join("eval-live", "report-"+time.Now().Format("0102-150405")+".json")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
|
||||
t.Fatalf("mkdir report dir: %v", err)
|
||||
}
|
||||
raw, _ := json.MarshalIndent(rep, "", " ")
|
||||
if err := os.WriteFile(outPath, raw, 0o644); err != nil {
|
||||
t.Fatalf("write report: %v", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n[live-eval] report: %s\n%s\n", outPath, renderMarkdown(rep))
|
||||
}
|
||||
|
||||
func renderMarkdown(rep *report) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# Live eval — %s\n\n- target: %s\n- model: %s\n- generated: %s\n\n", rep.GeneratedAt, rep.Target, rep.Model, rep.GeneratedAt)
|
||||
for _, tk := range rep.Tasks {
|
||||
fmt.Fprintf(&b, "## %s\n\n| turn | status | tools | fails | regens | api探查 | 读原件 | 计划 | 检查 | 工件 | 时长s |\n|---|---|---|---|---|---|---|---|---|---|---|\n", tk.ID)
|
||||
for _, turn := range tk.Turns {
|
||||
fmt.Fprintf(&b, "| %s | %s | %d | %d | %d | %d | %d | %d | %d | %d | %.0f |\n",
|
||||
truncate(turn.User, 18), turn.Status, turn.Metrics.ToolCalls, turn.Metrics.ToolFailures,
|
||||
turn.Metrics.FullRegens, turn.Metrics.APIDiscovery, turn.Metrics.ReadsOriginal,
|
||||
turn.Metrics.PlanUpdates, turn.Metrics.VerifierChecks, len(turn.Artifacts),
|
||||
float64(turn.DurationMs)/1000)
|
||||
}
|
||||
if len(tk.UnverifiedFacts) > 0 {
|
||||
fmt.Fprintf(&b, "\n未核实事实(工件里出现、材料里找不到):%s\n", strings.Join(tk.UnverifiedFacts, "、"))
|
||||
if len(tk.UnverifiedUnmarkedFacts) > 0 {
|
||||
fmt.Fprintf(&b, "其中未标注(无出处且无目标/预计/假设标注,E3 闸门口径):%s\n", strings.Join(tk.UnverifiedUnmarkedFacts, "、"))
|
||||
}
|
||||
}
|
||||
if len(tk.FinalVsArtifact) > 0 {
|
||||
fmt.Fprintf(&b, "\n终轮声称但工件不存在的章节:%s\n", strings.Join(tk.FinalVsArtifact, ";"))
|
||||
}
|
||||
fmt.Fprint(&b, "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//go:build live
|
||||
|
||||
// Package live 是真实后端 + 真实模型的任务级评测(Phase 0 基线)。
|
||||
//
|
||||
// 用法(对准一个已配置好模型的运行实例):
|
||||
//
|
||||
// AGENT_EVAL_BASE=http://127.0.0.1:18799 \
|
||||
// AGENT_EVAL_MATERIALS=~/.agent-system/eval/materials \
|
||||
// AGENT_EVAL_OUT=/tmp/before.json \
|
||||
// go test ./eval/live/ -tags live -run TestLiveSuite -v -timeout 90m
|
||||
//
|
||||
// 评测器只用公开 HTTP 面(上传→建会话→发消息→轮询),指标从消息 parts、
|
||||
// run usage 与工件内容计算;不触碰目标实例数据库。HITL 卡自动选第一个选项,
|
||||
// 保证无人值守跑完。产出 JSON + Markdown 报告;任务集见 tasks.json。
|
||||
// 真实材料不进仓:materials 目录按 eval/README 的口径放本机。
|
||||
package live
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type task struct {
|
||||
ID string `json:"id"`
|
||||
Material string `json:"material,omitempty"`
|
||||
Turns []string `json:"turns"`
|
||||
}
|
||||
|
||||
type part struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Process bool `json:"process"`
|
||||
ToolCall *struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
Duration float64 `json:"durationMs"`
|
||||
} `json:"toolCall"`
|
||||
ToolResult *struct {
|
||||
CallID string `json:"callId"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
} `json:"toolResult"`
|
||||
Check *struct {
|
||||
Passed bool `json:"passed"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"check"`
|
||||
Artifact *struct {
|
||||
ArtifactID string `json:"artifactId"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
} `json:"artifact"`
|
||||
}
|
||||
|
||||
type message struct {
|
||||
ID string `json:"id"`
|
||||
RunID string `json:"runId"`
|
||||
Role string `json:"role"`
|
||||
Parts []part `json:"parts"`
|
||||
Excluded bool `json:"excluded"`
|
||||
}
|
||||
|
||||
type run struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
EndedAt string `json:"endedAt"`
|
||||
Usage *struct {
|
||||
PromptTokens int `json:"promptTokens"`
|
||||
CompletionTokens int `json:"completionTokens"`
|
||||
TotalTokens int `json:"totalTokens"`
|
||||
DurationMs int `json:"durationMs"`
|
||||
ToolCalls int `json:"toolCalls"`
|
||||
} `json:"usage"`
|
||||
Error any `json:"error"`
|
||||
}
|
||||
|
||||
type artifactMeta struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
MIME string `json:"mime"`
|
||||
SourceRun string `json:"sourceRun"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type hitlCard struct {
|
||||
ID string `json:"id"`
|
||||
Question string `json:"question"`
|
||||
Choices []struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
} `json:"choices"`
|
||||
AllowFreeInput bool `json:"allowFreeInput"`
|
||||
}
|
||||
|
||||
type client struct {
|
||||
base string
|
||||
http *http.Client
|
||||
t *testing.T
|
||||
model string
|
||||
}
|
||||
|
||||
func (c *client) do(method, path string, body any, out any) error {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, c.base+path, rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, truncate(string(raw), 300))
|
||||
}
|
||||
if out != nil {
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
|
||||
func TestLiveSuite(t *testing.T) {
|
||||
base := strings.TrimSpace(os.Getenv("AGENT_EVAL_BASE"))
|
||||
if base == "" {
|
||||
t.Skip("AGENT_EVAL_BASE not set; live eval requires a configured backend instance")
|
||||
}
|
||||
materialsDir := os.Getenv("AGENT_EVAL_MATERIALS")
|
||||
if materialsDir == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
materialsDir = filepath.Join(home, ".agent-system", "eval", "materials")
|
||||
}
|
||||
if _, err := os.Stat(materialsDir); err != nil {
|
||||
t.Skipf("materials dir not available: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile("tasks.json")
|
||||
if err != nil {
|
||||
t.Fatalf("read tasks.json: %v", err)
|
||||
}
|
||||
var tasks []task
|
||||
if err := json.Unmarshal(raw, &tasks); err != nil {
|
||||
t.Fatalf("parse tasks.json: %v", err)
|
||||
}
|
||||
if filter := os.Getenv("AGENT_EVAL_TASKS"); filter != "" {
|
||||
want := map[string]bool{}
|
||||
for _, id := range strings.Split(filter, ",") {
|
||||
want[strings.TrimSpace(id)] = true
|
||||
}
|
||||
var kept []task
|
||||
for _, tk := range tasks {
|
||||
if want[tk.ID] {
|
||||
kept = append(kept, tk)
|
||||
}
|
||||
}
|
||||
tasks = kept
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
t.Fatal("no tasks selected")
|
||||
}
|
||||
turnTimeout := 20 * time.Minute
|
||||
if v := os.Getenv("AGENT_EVAL_TURN_TIMEOUT"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
turnTimeout = d
|
||||
}
|
||||
}
|
||||
c := &client{base: base, http: &http.Client{Timeout: 120 * time.Second}, t: t}
|
||||
// /api/settings 的形状是 {defaults, project, schema, user};模型播在 user 作用域。
|
||||
var settings struct {
|
||||
User struct {
|
||||
Models struct {
|
||||
Chat struct {
|
||||
Model string `json:"model"`
|
||||
} `json:"chat"`
|
||||
} `json:"models"`
|
||||
} `json:"user"`
|
||||
}
|
||||
_ = c.do("GET", "/api/settings", nil, &settings)
|
||||
c.model = settings.User.Models.Chat.Model
|
||||
|
||||
proj := struct {
|
||||
ID string `json:"id"`
|
||||
}{}
|
||||
if err := c.do("POST", "/api/projects", map[string]string{"name": "eval-" + time.Now().Format("0102-150405")}, &proj); err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
|
||||
rep := report{Target: base, Model: c.model, GeneratedAt: time.Now().UTC().Format(time.RFC3339)}
|
||||
for _, tk := range tasks {
|
||||
rep.Tasks = append(rep.Tasks, runTask(t, c, proj.ID, tk, materialsDir, turnTimeout))
|
||||
}
|
||||
writeReport(t, &rep)
|
||||
}
|
||||
|
||||
type turnResult struct {
|
||||
User string `json:"user"`
|
||||
RunID string `json:"runId"`
|
||||
Status string `json:"status"`
|
||||
FinalText string `json:"finalText,omitempty"`
|
||||
Usage *run `json:"usage,omitempty"`
|
||||
Metrics turnMetrics `json:"metrics"`
|
||||
Checks []checkRecord `json:"checks,omitempty"`
|
||||
Artifacts []string `json:"artifacts,omitempty"`
|
||||
Issues []string `json:"issues,omitempty"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
}
|
||||
|
||||
type turnMetrics struct {
|
||||
ToolCalls int `json:"toolCalls"`
|
||||
ToolFailures int `json:"toolFailures"`
|
||||
FullRegens int `json:"fullRegens"`
|
||||
APIDiscovery int `json:"apiDiscovery"`
|
||||
ReadsOriginal int `json:"readsOriginal"`
|
||||
PlanUpdates int `json:"planUpdates"`
|
||||
VerifierChecks int `json:"verifierChecks"`
|
||||
}
|
||||
|
||||
type checkRecord struct {
|
||||
Passed bool `json:"passed"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type taskResult struct {
|
||||
ID string `json:"id"`
|
||||
Turns []turnResult `json:"turns"`
|
||||
Total turnMetrics `json:"total"`
|
||||
UnverifiedFacts []string `json:"unverifiedFacts,omitempty"`
|
||||
// UnverifiedUnmarkedFacts:无出处且未按「目标/预计/假设」标注的子集
|
||||
// (E3 口径:严格口径 unverifiedFacts 保留原义以维持历史可比性)。
|
||||
UnverifiedUnmarkedFacts []string `json:"unverifiedUnmarkedFacts,omitempty"`
|
||||
FinalVsArtifact []string `json:"finalVsArtifact,omitempty"`
|
||||
}
|
||||
|
||||
type report struct {
|
||||
Target string `json:"target"`
|
||||
Model string `json:"model,omitempty"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Tasks []taskResult `json:"tasks"`
|
||||
}
|
||||
|
||||
func runTask(t *testing.T, c *client, projectID string, tk task, materialsDir string, turnTimeout time.Duration) taskResult {
|
||||
t.Helper()
|
||||
conv := struct {
|
||||
ID string `json:"id"`
|
||||
}{}
|
||||
if err := c.do("POST", "/api/projects/"+projectID+"/conversations", map[string]string{"title": "eval-" + tk.ID}, &conv); err != nil {
|
||||
t.Fatalf("[%s] create conversation: %v", tk.ID, err)
|
||||
}
|
||||
var attachmentIDs []string
|
||||
materialText := ""
|
||||
if tk.Material != "" {
|
||||
path := filepath.Join(materialsDir, tk.Material)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("[%s] open material: %v", tk.ID, err)
|
||||
}
|
||||
up := c.upload(t, projectID, tk.Material, raw)
|
||||
attachmentIDs = append(attachmentIDs, up)
|
||||
materialText = docxTextIfDocx(t, tk.Material, raw)
|
||||
}
|
||||
tr := taskResult{ID: tk.ID}
|
||||
for i, turnText := range tk.Turns {
|
||||
tr.Turns = append(tr.Turns, runTurn(t, c, projectID, conv.ID, turnText, attachmentIDs, turnTimeout))
|
||||
attachmentIDs = nil // 材料只在第一轮附带;后续轮考验跨轮上下文
|
||||
_ = i
|
||||
}
|
||||
// 汇总
|
||||
for _, turn := range tr.Turns {
|
||||
tr.Total.ToolCalls += turn.Metrics.ToolCalls
|
||||
tr.Total.ToolFailures += turn.Metrics.ToolFailures
|
||||
tr.Total.FullRegens += turn.Metrics.FullRegens
|
||||
tr.Total.APIDiscovery += turn.Metrics.APIDiscovery
|
||||
tr.Total.ReadsOriginal += turn.Metrics.ReadsOriginal
|
||||
tr.Total.PlanUpdates += turn.Metrics.PlanUpdates
|
||||
tr.Total.VerifierChecks += turn.Metrics.VerifierChecks
|
||||
}
|
||||
// 事实核查:最后一个带工件的回合 vs 材料
|
||||
if materialText != "" {
|
||||
last := lastArtifactTurn(tr)
|
||||
if last != nil && len(last.Artifacts) > 0 {
|
||||
artText := ""
|
||||
for _, name := range last.Artifacts {
|
||||
artText += downloadArtifactText(t, c, projectID, name)
|
||||
}
|
||||
tr.UnverifiedFacts = unverifiedFacts(artText, materialText)
|
||||
tr.UnverifiedUnmarkedFacts = unmarkedFacts(tr.UnverifiedFacts, artText)
|
||||
tr.FinalVsArtifact = finalVsArtifact(last, artText)
|
||||
}
|
||||
}
|
||||
return tr
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"id": "minutes-basic",
|
||||
"material": "meeting-rd-agent-asr.docx",
|
||||
"turns": ["帮我总结一份会议纪要"]
|
||||
},
|
||||
{
|
||||
"id": "plan-followup",
|
||||
"material": "meeting-rd-agent-asr.docx",
|
||||
"turns": [
|
||||
"帮我总结一份会议纪要",
|
||||
"根据这个纪要,制定一个方案",
|
||||
"方案太简要了,给我一个详细的方案和计划"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "qa-citation",
|
||||
"material": "meeting-rd-agent-asr.docx",
|
||||
"turns": ["这次会议最主要的事情是什么"]
|
||||
},
|
||||
{
|
||||
"id": "minutes-pm-tool",
|
||||
"material": "meeting-pm-tool-asr.docx",
|
||||
"turns": ["生成会议纪要"]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,407 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
)
|
||||
|
||||
type accountDirectorySlot struct {
|
||||
mu sync.RWMutex
|
||||
pluginID string
|
||||
resolver extension.AccountDirectoryScopeResolver
|
||||
secret [32]byte
|
||||
keyVersion int
|
||||
}
|
||||
|
||||
func newAccountDirectorySlot(st *store.Store) (*accountDirectorySlot, error) {
|
||||
key, err := st.GetAccountCursorKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &accountDirectorySlot{secret: key.Secret, keyVersion: key.Version}, nil
|
||||
}
|
||||
|
||||
func (s *accountDirectorySlot) register(pluginID string, fn extension.AccountDirectoryScopeResolver) error {
|
||||
if fn == nil || pluginID != "rdm" {
|
||||
return store.ErrAccountForbidden
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.resolver != nil {
|
||||
return errors.New("account directory resolver already registered")
|
||||
}
|
||||
s.pluginID, s.resolver = pluginID, fn
|
||||
return nil
|
||||
}
|
||||
|
||||
type directoryPolicy struct {
|
||||
allowed map[string]bool
|
||||
match []string
|
||||
candidates map[string]bool
|
||||
owners map[string]bool
|
||||
rows []store.DirectoryAccountRow
|
||||
all bool
|
||||
status string
|
||||
version string
|
||||
}
|
||||
|
||||
type directoryCursor struct {
|
||||
KeyVersion int `json:"k"`
|
||||
Version string `json:"v"`
|
||||
Owner string `json:"o"`
|
||||
UserID string `json:"u"`
|
||||
}
|
||||
|
||||
type directoryVisibleRow struct {
|
||||
UserID string
|
||||
Owner string
|
||||
Enabled bool
|
||||
Name string
|
||||
StaffNo string
|
||||
}
|
||||
|
||||
func knownDirectoryField(field string) bool {
|
||||
return field == "owner" || field == "name" || field == "staffNo"
|
||||
}
|
||||
|
||||
func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDirectoryQuery, loadRows bool) (directoryPolicy, error) {
|
||||
var p directoryPolicy
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return p, err
|
||||
}
|
||||
if h.actor == "" || h.caps.st == nil || h.caps.directory == nil || q.SchemaVersion != extension.AccountDirectorySchemaVersion {
|
||||
return p, store.ErrAccountForbidden
|
||||
}
|
||||
actor, err := h.caps.st.GetUserAuthState(h.actor)
|
||||
if err != nil || !actor.Enabled {
|
||||
return p, store.ErrAccountForbidden
|
||||
}
|
||||
if q.Mode != extension.AccountDirectoryModeExact && q.Mode != extension.AccountDirectoryModeFuzzy {
|
||||
return p, store.ErrInvalidAccountProfile
|
||||
}
|
||||
if !utf8.ValidString(q.Query) || utf8.RuneCountInString(q.Query) > 128 {
|
||||
return p, store.ErrInvalidAccountProfile
|
||||
}
|
||||
for _, r := range q.Query {
|
||||
if unicode.IsControl(r) {
|
||||
return p, store.ErrInvalidAccountProfile
|
||||
}
|
||||
}
|
||||
p.status = q.Status
|
||||
if p.status == "" {
|
||||
p.status = "enabled"
|
||||
}
|
||||
if p.status != "enabled" && p.status != "disabled" && p.status != "all" {
|
||||
return p, store.ErrInvalidAccountProfile
|
||||
}
|
||||
var scope extension.AccountDirectoryScope
|
||||
switch q.Purpose {
|
||||
case extension.AccountDirectoryPurposeSystemUsers:
|
||||
if !actor.IsAdmin || q.ProjectID != "" || q.DocumentID != "" {
|
||||
return p, store.ErrAccountForbidden
|
||||
}
|
||||
scope = extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name", "staffNo"},
|
||||
MatchFields: []string{"owner", "name", "staffNo"}, AllCandidates: true, ScopeVersion: "system.users.v1"}
|
||||
case extension.AccountDirectoryPurposeProjectMembers, extension.AccountDirectoryPurposeDocumentDuties:
|
||||
if h.pluginID != "rdm" || q.ProjectID == "" || (q.Purpose == extension.AccountDirectoryPurposeDocumentDuties && q.DocumentID == "") ||
|
||||
(q.Purpose == extension.AccountDirectoryPurposeProjectMembers && q.DocumentID != "") || p.status != "enabled" {
|
||||
return p, store.ErrAccountForbidden
|
||||
}
|
||||
h.caps.directory.mu.RLock()
|
||||
resolver, pluginID := h.caps.directory.resolver, h.caps.directory.pluginID
|
||||
h.caps.directory.mu.RUnlock()
|
||||
if resolver == nil || pluginID != h.pluginID {
|
||||
return p, store.ErrAccountForbidden
|
||||
}
|
||||
scope, err = resolver(ctx, extension.AccountDirectoryScopeRequest{
|
||||
Actor: h.actor, PluginID: h.pluginID, Purpose: q.Purpose, ProjectID: q.ProjectID, DocumentID: q.DocumentID,
|
||||
})
|
||||
if err != nil {
|
||||
return p, store.ErrAccountForbidden
|
||||
}
|
||||
if q.Purpose == extension.AccountDirectoryPurposeDocumentDuties && scope.AllCandidates {
|
||||
return p, store.ErrAccountForbidden
|
||||
}
|
||||
default:
|
||||
return p, store.ErrAccountForbidden
|
||||
}
|
||||
if scope.ScopeVersion == "" {
|
||||
return p, store.ErrAccountForbidden
|
||||
}
|
||||
p.allowed = map[string]bool{"owner": true}
|
||||
for _, field := range scope.AllowedFields {
|
||||
if !knownDirectoryField(field) {
|
||||
return p, store.ErrAccountForbidden
|
||||
}
|
||||
p.allowed[field] = true
|
||||
}
|
||||
matchAllowed := map[string]bool{}
|
||||
for _, field := range scope.MatchFields {
|
||||
if !knownDirectoryField(field) || !p.allowed[field] {
|
||||
return p, store.ErrAccountForbidden
|
||||
}
|
||||
matchAllowed[field] = true
|
||||
}
|
||||
requested := q.SearchFields
|
||||
if len(requested) == 0 {
|
||||
requested = []string{"owner", "name"}
|
||||
}
|
||||
for _, field := range requested {
|
||||
if !matchAllowed[field] {
|
||||
return p, store.ErrAccountForbidden
|
||||
}
|
||||
p.match = append(p.match, field)
|
||||
}
|
||||
sort.Strings(p.match)
|
||||
p.candidates = make(map[string]bool, len(scope.CandidateIDs))
|
||||
p.owners = make(map[string]bool, len(scope.CandidateOwners))
|
||||
for _, id := range scope.CandidateIDs {
|
||||
if id == "" {
|
||||
return p, store.ErrAccountForbidden
|
||||
}
|
||||
p.candidates[id] = true
|
||||
}
|
||||
for _, owner := range scope.CandidateOwners {
|
||||
if owner == "" {
|
||||
return p, store.ErrAccountForbidden
|
||||
}
|
||||
p.owners[owner] = true
|
||||
}
|
||||
p.all = scope.AllCandidates
|
||||
if q.Purpose == extension.AccountDirectoryPurposeSystemUsers {
|
||||
p.all = true
|
||||
}
|
||||
visible := make([]directoryVisibleRow, 0)
|
||||
if loadRows {
|
||||
rows, err := h.caps.st.ListDirectoryAccountRows()
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if !directoryRowAllowed(row, p) {
|
||||
continue
|
||||
}
|
||||
p.rows = append(p.rows, row)
|
||||
item := directoryVisibleRow{UserID: row.UserID, Owner: row.Owner, Enabled: row.Enabled}
|
||||
if p.allowed["name"] {
|
||||
item.Name = row.Name
|
||||
}
|
||||
if p.allowed["staffNo"] {
|
||||
item.StaffNo = row.StaffNo
|
||||
}
|
||||
visible = append(visible, item)
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(p.candidates))
|
||||
for id := range p.candidates {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
owners := make([]string, 0, len(p.owners))
|
||||
for owner := range p.owners {
|
||||
owners = append(owners, owner)
|
||||
}
|
||||
sort.Strings(owners)
|
||||
allowed := make([]string, 0, len(p.allowed))
|
||||
for field := range p.allowed {
|
||||
allowed = append(allowed, field)
|
||||
}
|
||||
sort.Strings(allowed)
|
||||
fingerprint := struct {
|
||||
Actor, Plugin, Purpose, Project, Document, Mode, Query, Status, Scope string
|
||||
Match, Allowed, IDs, Owners []string
|
||||
Visible []directoryVisibleRow
|
||||
All bool
|
||||
}{Actor: h.actor, Plugin: h.pluginID, Purpose: q.Purpose, Project: q.ProjectID, Document: q.DocumentID,
|
||||
Mode: q.Mode, Query: strings.TrimSpace(q.Query), Status: p.status,
|
||||
Scope: scope.ScopeVersion, Match: p.match, Allowed: allowed, IDs: ids, Owners: owners, Visible: visible, All: p.all}
|
||||
encoded, err := json.Marshal(fingerprint)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
digest := sha256.Sum256(encoded)
|
||||
p.version = hex.EncodeToString(digest[:])
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *accountDirectorySlot) encodeCursor(c directoryCursor) (string, error) {
|
||||
c.KeyVersion = s.keyVersion
|
||||
data, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
mac := hmac.New(sha256.New, s.secret[:])
|
||||
_, _ = mac.Write([]byte("iam-directory-cursor-v1\x00"))
|
||||
_, _ = mac.Write(data)
|
||||
return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func (s *accountDirectorySlot) decodeCursor(token, version string) (directoryCursor, error) {
|
||||
var c directoryCursor
|
||||
payload, signature, found := strings.Cut(token, ".")
|
||||
if !found {
|
||||
return c, store.ErrConflict
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(payload)
|
||||
if err != nil || len(data) > 1024 {
|
||||
return c, store.ErrConflict
|
||||
}
|
||||
provided, err := base64.RawURLEncoding.DecodeString(signature)
|
||||
if err != nil {
|
||||
return c, store.ErrConflict
|
||||
}
|
||||
mac := hmac.New(sha256.New, s.secret[:])
|
||||
_, _ = mac.Write([]byte("iam-directory-cursor-v1\x00"))
|
||||
_, _ = mac.Write(data)
|
||||
if !hmac.Equal(provided, mac.Sum(nil)) || json.Unmarshal(data, &c) != nil || c.KeyVersion != s.keyVersion || c.Version != version || c.Owner == "" || c.UserID == "" {
|
||||
return directoryCursor{}, store.ErrConflict
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func directoryEntry(row store.DirectoryAccountRow, p directoryPolicy) extension.AccountDirectoryEntry {
|
||||
entry := extension.AccountDirectoryEntry{UserID: row.UserID, Owner: row.Owner, Valid: row.Enabled}
|
||||
if p.allowed["name"] {
|
||||
entry.Name = row.Name
|
||||
}
|
||||
if p.allowed["staffNo"] {
|
||||
entry.StaffNo = row.StaffNo
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func directoryMatches(row store.DirectoryAccountRow, fields []string, mode, query string) bool {
|
||||
for _, field := range fields {
|
||||
value := row.Owner
|
||||
switch field {
|
||||
case "name":
|
||||
value = row.Name
|
||||
case "staffNo":
|
||||
value = row.StaffNo
|
||||
}
|
||||
if mode == extension.AccountDirectoryModeExact && strings.EqualFold(value, query) {
|
||||
return true
|
||||
}
|
||||
if mode == extension.AccountDirectoryModeFuzzy && strings.Contains(strings.ToLower(value), strings.ToLower(query)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func directoryRowAllowed(row store.DirectoryAccountRow, p directoryPolicy) bool {
|
||||
if !p.all && !p.candidates[row.UserID] && !p.owners[row.Owner] {
|
||||
return false
|
||||
}
|
||||
return p.status == "all" || (p.status == "enabled" && row.Enabled) || (p.status == "disabled" && !row.Enabled)
|
||||
}
|
||||
|
||||
func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.AccountDirectoryQuery) (extension.AccountDirectoryPage, error) {
|
||||
query := strings.TrimSpace(q.Query)
|
||||
p, err := h.directoryPolicy(ctx, q, query != "")
|
||||
if err != nil {
|
||||
return extension.AccountDirectoryPage{}, err
|
||||
}
|
||||
maxLimit := 50
|
||||
if q.Purpose == extension.AccountDirectoryPurposeSystemUsers {
|
||||
maxLimit = 100
|
||||
}
|
||||
if q.Limit == 0 {
|
||||
q.Limit = 20
|
||||
}
|
||||
if q.Limit < 1 || q.Limit > maxLimit {
|
||||
return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile
|
||||
}
|
||||
page := extension.AccountDirectoryPage{Entries: []extension.AccountDirectoryEntry{}, DirectoryVersion: p.version}
|
||||
if query == "" {
|
||||
if q.Cursor != "" {
|
||||
return extension.AccountDirectoryPage{}, store.ErrConflict
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
if q.Mode == extension.AccountDirectoryModeFuzzy && utf8.RuneCountInString(query) < 2 {
|
||||
return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile
|
||||
}
|
||||
var after directoryCursor
|
||||
if q.Cursor != "" {
|
||||
after, err = h.caps.directory.decodeCursor(q.Cursor, p.version)
|
||||
if err != nil {
|
||||
return extension.AccountDirectoryPage{}, err
|
||||
}
|
||||
}
|
||||
matched := make([]store.DirectoryAccountRow, 0, q.Limit+1)
|
||||
for _, row := range p.rows {
|
||||
if !directoryMatches(row, p.match, q.Mode, query) {
|
||||
continue
|
||||
}
|
||||
if after.Owner != "" && (row.Owner < after.Owner || (row.Owner == after.Owner && row.UserID <= after.UserID)) {
|
||||
continue
|
||||
}
|
||||
matched = append(matched, row)
|
||||
if len(matched) > q.Limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
more := len(matched) > q.Limit
|
||||
if more {
|
||||
matched = matched[:q.Limit]
|
||||
}
|
||||
for _, row := range matched {
|
||||
page.Entries = append(page.Entries, directoryEntry(row, p))
|
||||
}
|
||||
if more {
|
||||
last := matched[len(matched)-1]
|
||||
page.NextCursor, err = h.caps.directory.encodeCursor(directoryCursor{Version: p.version, Owner: last.Owner, UserID: last.UserID})
|
||||
if err != nil {
|
||||
return extension.AccountDirectoryPage{}, err
|
||||
}
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (h inprocHost) LookupAccountDirectory(ctx context.Context, q extension.AccountDirectoryQuery, userID string) (extension.AccountDirectoryLookup, error) {
|
||||
if userID == "" || q.Cursor != "" {
|
||||
return extension.AccountDirectoryLookup{}, store.ErrInvalidAccountProfile
|
||||
}
|
||||
p, err := h.directoryPolicy(ctx, q, true)
|
||||
if err != nil {
|
||||
return extension.AccountDirectoryLookup{}, err
|
||||
}
|
||||
for _, row := range p.rows {
|
||||
if row.UserID == userID {
|
||||
return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil
|
||||
}
|
||||
}
|
||||
return extension.AccountDirectoryLookup{}, store.ErrNotFound
|
||||
}
|
||||
|
||||
func (h inprocHost) LookupAccountDirectoryOwner(ctx context.Context, q extension.AccountDirectoryQuery, owner string) (extension.AccountDirectoryLookup, error) {
|
||||
if owner == "" || q.Cursor != "" {
|
||||
return extension.AccountDirectoryLookup{}, store.ErrInvalidAccountProfile
|
||||
}
|
||||
p, err := h.directoryPolicy(ctx, q, true)
|
||||
if err != nil {
|
||||
return extension.AccountDirectoryLookup{}, err
|
||||
}
|
||||
for _, row := range p.rows {
|
||||
if row.Owner == owner {
|
||||
return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil
|
||||
}
|
||||
}
|
||||
return extension.AccountDirectoryLookup{}, store.ErrNotFound
|
||||
}
|
||||
|
||||
var _ extension.AccountDirectoryProvider = inprocHost{}
|
||||
@@ -0,0 +1,221 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
)
|
||||
|
||||
func openDirectoryTestStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("AGENT_PG_TEST_DSN")
|
||||
if dsn == "" {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "directory.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
admin, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
schema := "t_" + strings.ToLower(store.NewID("dir"))
|
||||
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := admin.Exec(`CREATE SCHEMA ` + schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = admin.Exec(`DROP SCHEMA IF EXISTS ` + schema + ` CASCADE`)
|
||||
_ = admin.Close()
|
||||
})
|
||||
separator := "?"
|
||||
if strings.Contains(dsn, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
st, err := store.OpenPostgres(dsn+separator+"search_path="+schema+",public", filepath.Join(t.TempDir(), "artifacts"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func TestAccountDirectoryAppliesDomainScopeBeforeSearchAndCursor(t *testing.T) {
|
||||
st := openDirectoryTestStore(t)
|
||||
t.Logf("directory provider dialect=%s", st.Dialect())
|
||||
if err := st.CreateAccount("admin", "", "hash", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alice, err := st.CreatePendingAccount("admin", "alice", store.AccountProfile{Name: "同名", StaffNo: "hidden-a"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bob, err := st.CreatePendingAccount("admin", "bob", store.AccountProfile{Name: "同名", StaffNo: "hidden-b"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outsider, err := st.CreatePendingAccount("admin", "outsider", store.AccountProfile{Name: "同名", StaffNo: "hidden-x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slot, err := newAccountDirectorySlot(st)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := inprocHost{caps: hostcaps{st: st, directory: slot}, pluginID: "rdm", actor: "admin", owner: "admin"}
|
||||
query := extension.AccountDirectoryQuery{SchemaVersion: extension.AccountDirectorySchemaVersion,
|
||||
Purpose: extension.AccountDirectoryPurposeProjectMembers, ProjectID: "project-1",
|
||||
Mode: extension.AccountDirectoryModeFuzzy, Query: "同名", Limit: 1}
|
||||
if _, err := h.QueryAccountDirectory(context.Background(), query); err == nil {
|
||||
t.Fatal("domain directory ran without trusted resolver")
|
||||
}
|
||||
if err := (pluginRegistry{pluginID: "rdm", caps: h.caps}).RegisterAccountDirectoryScopeResolver(func(context.Context, extension.AccountDirectoryScopeRequest) (extension.AccountDirectoryScope, error) {
|
||||
return extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name"}, MatchFields: []string{"owner", "name"},
|
||||
CandidateIDs: []string{alice.ID, bob.ID}, ScopeVersion: "scope-1"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := h.QueryAccountDirectory(context.Background(), query)
|
||||
if err != nil || len(page.Entries) != 1 || page.Entries[0].UserID != alice.ID || page.Entries[0].StaffNo != "" || page.NextCursor == "" {
|
||||
t.Fatalf("first scoped page: %+v %v", page, err)
|
||||
}
|
||||
query.Cursor = page.NextCursor
|
||||
otherSlot, err := newAccountDirectorySlot(st)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherSlot.pluginID, otherSlot.resolver = slot.pluginID, slot.resolver
|
||||
otherHost := h
|
||||
otherHost.caps.directory = otherSlot
|
||||
second, err := otherHost.QueryAccountDirectory(context.Background(), query)
|
||||
if err != nil || len(second.Entries) != 1 || second.Entries[0].UserID != bob.ID || second.NextCursor != "" {
|
||||
t.Fatalf("second scoped page on another host: %+v %v", second, err)
|
||||
}
|
||||
query.Cursor = ""
|
||||
query.Query = "hidden-x"
|
||||
if found, err := h.QueryAccountDirectory(context.Background(), query); err != nil || len(found.Entries) != 0 {
|
||||
t.Fatalf("hidden staff number matched: %+v %v", found, err)
|
||||
}
|
||||
query.SearchFields = []string{"staffNo"}
|
||||
if _, err := h.QueryAccountDirectory(context.Background(), query); err == nil {
|
||||
t.Fatal("hidden staff number accepted as search field")
|
||||
}
|
||||
query.SearchFields = nil
|
||||
query.Query = "同名"
|
||||
if hit, err := h.LookupAccountDirectoryOwner(context.Background(), query, "alice"); err != nil || hit.Entry.UserID != alice.ID {
|
||||
t.Fatalf("explicit owner lookup failed to resolve stable ID: %+v %v", hit, err)
|
||||
}
|
||||
if _, err := h.LookupAccountDirectoryOwner(context.Background(), query, "outsider"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("owner lookup leaked outsider: %v", err)
|
||||
}
|
||||
if _, err := h.LookupAccountDirectory(context.Background(), query, "not-in-scope"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("exact lookup leaked outsider: %v", err)
|
||||
}
|
||||
page, err = h.QueryAccountDirectory(context.Background(), query)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion, map[string]string{"phone": "private-edit"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
query.Cursor = page.NextCursor
|
||||
if _, err := h.QueryAccountDirectory(context.Background(), query); err != nil {
|
||||
t.Fatalf("private edit invalidated candidate cursor: %v", err)
|
||||
}
|
||||
if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion+1, map[string]string{"staffNo": "still-hidden"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.QueryAccountDirectory(context.Background(), query); err != nil {
|
||||
t.Fatalf("unavailable field invalidated candidate cursor: %v", err)
|
||||
}
|
||||
if _, err := st.PatchAccountProfile("admin", outsider.ID, outsider.ProfileVersion, map[string]string{"name": "out-of-scope-change"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.QueryAccountDirectory(context.Background(), query); err != nil {
|
||||
t.Fatalf("out-of-scope edit invalidated candidate cursor: %v", err)
|
||||
}
|
||||
if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion+2, map[string]string{"name": "changed"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.QueryAccountDirectory(context.Background(), query); !errors.Is(err, store.ErrConflict) {
|
||||
t.Fatalf("visible edit did not invalidate cursor: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPGDirectoryCursorContinuesInAnotherProcess(t *testing.T) {
|
||||
baseDSN := os.Getenv("AGENT_PG_TEST_DSN")
|
||||
if baseDSN == "" {
|
||||
t.Skip("isolated PostgreSQL test DSN required")
|
||||
}
|
||||
if os.Getenv("IAM_U3_DIRECTORY_CHILD") == "1" {
|
||||
schema := os.Getenv("IAM_U3_DIRECTORY_SCHEMA")
|
||||
separator := "?"
|
||||
if strings.Contains(baseDSN, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
st, err := store.OpenExistingPostgres(baseDSN+separator+"search_path="+schema+",public", filepath.Join(t.TempDir(), "artifacts"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
slot, err := newAccountDirectorySlot(st)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := inprocHost{caps: hostcaps{st: st, directory: slot}, actor: "admin", owner: "admin"}
|
||||
q := extension.AccountDirectoryQuery{SchemaVersion: extension.AccountDirectorySchemaVersion,
|
||||
Purpose: extension.AccountDirectoryPurposeSystemUsers, Mode: extension.AccountDirectoryModeFuzzy,
|
||||
Query: "same", Limit: 1, Cursor: os.Getenv("IAM_U3_DIRECTORY_CURSOR")}
|
||||
page, err := h.QueryAccountDirectory(context.Background(), q)
|
||||
if err != nil || len(page.Entries) != 1 || page.Entries[0].Owner != "beta" {
|
||||
t.Fatalf("cross-process page: %+v %v", page, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
st := openDirectoryTestStore(t)
|
||||
if err := st.EnsureSystemIdentity("u3-directory-cross-process", "pg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, owner := range []string{"alpha", "beta"} {
|
||||
if _, err := st.CreatePendingAccount("admin", owner, store.AccountProfile{Name: "same"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
slot, err := newAccountDirectorySlot(st)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := inprocHost{caps: hostcaps{st: st, directory: slot}, actor: "admin", owner: "admin"}
|
||||
q := extension.AccountDirectoryQuery{SchemaVersion: extension.AccountDirectorySchemaVersion,
|
||||
Purpose: extension.AccountDirectoryPurposeSystemUsers, Mode: extension.AccountDirectoryModeFuzzy,
|
||||
Query: "same", Limit: 1}
|
||||
page, err := h.QueryAccountDirectory(context.Background(), q)
|
||||
if err != nil || len(page.Entries) != 1 || page.Entries[0].Owner != "alpha" || page.NextCursor == "" {
|
||||
t.Fatalf("initial page: %+v %v", page, err)
|
||||
}
|
||||
var schema string
|
||||
if err := st.DB().QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestPGDirectoryCursorContinuesInAnotherProcess$")
|
||||
cmd.Env = append(os.Environ(), "IAM_U3_DIRECTORY_CHILD=1", "IAM_U3_DIRECTORY_SCHEMA="+schema,
|
||||
"IAM_U3_DIRECTORY_CURSOR="+page.NextCursor)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("cross-process cursor failed: %v: %s", err, strings.ReplaceAll(string(out), baseDSN, "[REDACTED]"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// accountEffectiveAuthorization is shared by runmgr and in-process Host
|
||||
// capabilities. The implicit local singleton is the only auth-off missing-row
|
||||
// exception; an existing disabled local account remains denied.
|
||||
func accountEffectiveAuthorization(st *store.Store, authOn bool) func(context.Context, string) (bool, error) {
|
||||
return func(_ context.Context, owner string) (bool, error) {
|
||||
account, err := st.GetAccountByOwner(owner)
|
||||
if errors.Is(err, store.ErrNotFound) && !authOn && owner == store.OwnerDefault {
|
||||
return true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return account.Enabled, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func TestInprocHostRechecksEffectiveAccountOnEveryCapability(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/host.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.CreateAccount("alice", "", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check := accountEffectiveAuthorization(st, true)
|
||||
h := inprocHost{caps: hostcaps{st: st, accountGuard: func(owner string) error {
|
||||
valid, err := check(context.Background(), owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !valid {
|
||||
return store.ErrAccountForbidden
|
||||
}
|
||||
return nil
|
||||
}}, owner: "alice", actor: "alice", pluginID: "rdm"}
|
||||
if err := h.KVPut("before", "allowed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner=?`, "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.KVPut("after", "denied"); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled host wrote KV: %v", err)
|
||||
}
|
||||
if _, _, err := h.KVGet("before"); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled host read KV: %v", err)
|
||||
}
|
||||
if _, err := h.TaskCreate("test", "denied", ""); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled host created task: %v", err)
|
||||
}
|
||||
if _, err := h.ProjectNameAvailable("denied", ""); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled host used project capability: %v", err)
|
||||
}
|
||||
if err := h.Audit("action", "target", ""); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled host wrote audit: %v", err)
|
||||
}
|
||||
if _, err := h.Complete(context.Background(), "denied"); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled host reached model completion: %v", err)
|
||||
}
|
||||
if _, err := h.KnowledgeSearch(context.Background(), "", "denied", 1); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled host read knowledge: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostCompletionChecksAgainAfterModelSettingsRead(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/host-complete.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
requests := 0
|
||||
model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
requests++
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"late"}}]}`))
|
||||
}))
|
||||
defer model.Close()
|
||||
set := settings.NewService(st)
|
||||
set.SetSystemOverride(map[string]any{"models": map[string]any{"chat": map[string]any{"model": "test", "baseUrl": model.URL}}})
|
||||
checks := 0
|
||||
h := inprocHost{caps: hostcaps{st: st, set: set, accountGuard: func(string) error {
|
||||
checks++
|
||||
if checks >= 3 {
|
||||
return store.ErrAccountForbidden
|
||||
}
|
||||
return nil
|
||||
}}, owner: "alice", actor: "alice", pluginID: "rdm"}
|
||||
if _, err := h.Complete(context.Background(), "late"); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("completion after disable: %v", err)
|
||||
}
|
||||
if checks != 3 || requests != 0 {
|
||||
t.Fatalf("late completion reached model: checks=%d requests=%d", checks, requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImplicitLocalAccountExceptionIsAuthOffOnly(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/local.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
allowed, err := accountEffectiveAuthorization(st, false)(context.Background(), store.OwnerDefault)
|
||||
if err != nil || !allowed {
|
||||
t.Fatalf("implicit local denied: %v %v", allowed, err)
|
||||
}
|
||||
allowed, err = accountEffectiveAuthorization(st, true)(context.Background(), store.OwnerDefault)
|
||||
if !errors.Is(err, store.ErrNotFound) || allowed {
|
||||
t.Fatalf("auth-on missing account allowed: %v %v", allowed, err)
|
||||
}
|
||||
if err := st.EnsureUser(store.OwnerDefault, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner=?`, store.OwnerDefault); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allowed, err = accountEffectiveAuthorization(st, false)(context.Background(), store.OwnerDefault)
|
||||
if err != nil || allowed {
|
||||
t.Fatalf("disabled existing local account allowed: %v %v", allowed, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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.EnsureUser(o, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.CreateAPIKey(o, "e2e"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := st.DB().Exec(`UPDATE users SET is_admin=TRUE WHERE owner='alice'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := inprocHost{caps: hostcaps{st: st}, owner: "alice", actor: "alice"}
|
||||
if _, _, err := (inprocHost{caps: hostcaps{st: st}}).ListAccounts("", 2); err == nil {
|
||||
t.Fatal("legacy directory allowed a host without a trusted dispatch actor")
|
||||
}
|
||||
|
||||
// 首页(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)
|
||||
}
|
||||
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := h.ListAccounts("", 2); err == nil {
|
||||
t.Fatal("disabled administrator retained legacy directory access")
|
||||
}
|
||||
}
|
||||
|
||||
// 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, true); 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}, owner: "alice", actor: "alice"}
|
||||
if _, err := (inprocHost{caps: hostcaps{st: st}, owner: "bob", actor: "bob"}).SearchAccounts("alice", 50); err == nil {
|
||||
t.Fatal("ordinary actor gained legacy system directory search")
|
||||
}
|
||||
|
||||
// 姓名子串(同名区分场景):两个"大明/小明"都以"明"命中。
|
||||
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")
|
||||
}
|
||||
}
|
||||
+576
-94
@@ -30,12 +30,15 @@ import (
|
||||
"agent-native/runtime/telemetry"
|
||||
agenttools "agent-native/runtime/tools"
|
||||
nativeadp "agent-squad/adapters/native"
|
||||
"agent-system-backend/internal/blobstore"
|
||||
"agent-system-backend/internal/bus"
|
||||
"agent-system-backend/internal/cardsvc"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/config"
|
||||
"agent-system-backend/internal/genui"
|
||||
"agent-system-backend/internal/hostapi"
|
||||
"agent-system-backend/internal/intentgate"
|
||||
"agent-system-backend/internal/intentsem"
|
||||
"agent-system-backend/internal/pgbus"
|
||||
"agent-system-backend/internal/plugins"
|
||||
"agent-system-backend/internal/profiles"
|
||||
@@ -49,8 +52,10 @@ import (
|
||||
"agent-system-backend/internal/skills"
|
||||
"agent-system-backend/internal/squadadapter"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/surfacesvc"
|
||||
"agent-system-backend/internal/tools"
|
||||
"agent-system-backend/internal/workflow"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
@@ -65,6 +70,78 @@ type Options struct {
|
||||
InstanceID string // 空 = 主机名:监听地址;写入 runs.instance_id,启动对账只收口本实例
|
||||
InsecureListen bool
|
||||
AllowedHosts []string
|
||||
// DisableUploadIndexing EXT-9:true 时上传端点不自动写检索索引(发布决定
|
||||
// 移交下游命令层);零值=false=现状。由 link 层自 extension.Options 透传。
|
||||
DisableUploadIndexing bool
|
||||
// DisableConvCardMirror EXT-12:true 时会话卡不镜像进项目公共流(不发布
|
||||
// 即不持久,重放不含会话卡);零值=false=现状全量镜像。
|
||||
DisableConvCardMirror bool
|
||||
// DisableExecTools 启动级硬关闭执行原语(run_shell / run_python /
|
||||
// register_artifact 与小队内核执行配置):用户设置 runtime.exec 无法覆盖;
|
||||
// 零值=false=现状。
|
||||
DisableExecTools bool
|
||||
// ExecBackend 执行后端(EXT-16):空/direct=现状直启;sandbox=bwrap 隔离档。
|
||||
// 硬关闭 DisableExecTools 优先级高于后端选择。
|
||||
ExecBackend string
|
||||
// ExecDepsDir sandbox 档只读依赖层(site-packages 等,空=不挂):修复
|
||||
// --tmpfs /home 遮蔽 ~/.local 导致交付物生成链路不可用(F3)。
|
||||
ExecDepsDir string
|
||||
// ExecWorkspace = "overlay" 时 sandbox 档启用 S0 工作区(冻结基底+私有
|
||||
// upper+CAS 回收);空 = EXT-16 现状直写会话目录。
|
||||
ExecWorkspace string
|
||||
// Tier 部署档(协作存储 KTD1):空=auto(DSN→PG 否则 SQLite);local=SQLite;
|
||||
// team=PG fail-closed(缺 DSN/未认证拒绝启动;PG 故障拒绝就绪,不回落 SQLite)。
|
||||
Tier string
|
||||
// PGPool PG 连接预算(KTD12;零值走默认)。仅 PG 档生效。
|
||||
PGPool store.PoolOptions
|
||||
}
|
||||
|
||||
// instanceTag 稳定执行身份:tier=team 时强制 host:addr(多实例互异),
|
||||
// 本地档退化为 local(单实例语义)。
|
||||
func instanceTag(opt Options) string {
|
||||
if opt.Tier == "team" || opt.StorageDSN != "" {
|
||||
return hostTag() + ":" + opt.Addr
|
||||
}
|
||||
return "local"
|
||||
}
|
||||
|
||||
func hostTag() string {
|
||||
h, err := os.Hostname()
|
||||
if err != nil || h == "" {
|
||||
return "host"
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// tierName 输出部署档名(诊断统一拼写:空 = auto)。
|
||||
func tierName(opt Options) string {
|
||||
if opt.Tier == "" {
|
||||
return "auto"
|
||||
}
|
||||
return opt.Tier
|
||||
}
|
||||
|
||||
// TierRefusal 部署档门禁(KTD1):返回阻止启动的问题(空 = 放行)。
|
||||
// team 档 fail-closed:缺 DSN、未开认证拒绝;PG 故障的拒绝发生在其后的
|
||||
// OpenPostgres(Run 对 team 档的错误显式声明不回落 SQLite)。
|
||||
func TierRefusal(opt Options) []string {
|
||||
return config.Resolved{Config: config.Config{
|
||||
Addr: opt.Addr, AuthMode: opt.AuthMode, StorageDSN: opt.StorageDSN, Tier: opt.Tier,
|
||||
PGMaxOpen: opt.PGPool.MaxOpenConn, PGMaxIdle: opt.PGPool.MaxIdleConn,
|
||||
PGConnMaxIdleSec: opt.PGPool.ConnMaxIdleTimeSec, PGConnMaxLifetimeSec: opt.PGPool.ConnMaxLifetimeSec,
|
||||
PGQueryTimeoutSec: opt.PGPool.QueryTimeoutSec,
|
||||
}}.TierProblems()
|
||||
}
|
||||
|
||||
// latestVersion 返回已应用迁移的最高版本(0 = 空账本)。
|
||||
func latestVersion(versions []int) int {
|
||||
max := 0
|
||||
for _, v := range versions {
|
||||
if v > max {
|
||||
max = v
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。
|
||||
@@ -73,17 +150,28 @@ func Run(opt Options) error {
|
||||
Service: "agent-system", Module: "app", Level: logsys.LevelInfo,
|
||||
Sinks: []logsys.Sink{&sink.Console{Out: os.Stderr, Format: "json"}},
|
||||
})
|
||||
logger.Info("agent-system starting", "addr", opt.Addr, "data", opt.DataDir)
|
||||
logger.Info("agent-system starting", "addr", opt.Addr, "data", opt.DataDir, "tier", tierName(opt))
|
||||
resolved := config.Resolved{Config: config.Config{
|
||||
Addr: opt.Addr, AuthMode: opt.AuthMode, StorageDSN: opt.StorageDSN, InsecureListen: opt.InsecureListen,
|
||||
Tier: opt.Tier, PGMaxOpen: opt.PGPool.MaxOpenConn, PGMaxIdle: opt.PGPool.MaxIdleConn,
|
||||
PGConnMaxIdleSec: opt.PGPool.ConnMaxIdleTimeSec, PGConnMaxLifetimeSec: opt.PGPool.ConnMaxLifetimeSec,
|
||||
PGQueryTimeoutSec: opt.PGPool.QueryTimeoutSec,
|
||||
}}
|
||||
if msg := resolved.ListenRefusal(); msg != "" {
|
||||
return fmt.Errorf("listen guard: %s", msg)
|
||||
}
|
||||
// 部署档门禁(KTD1 fail-closed):team 档缺 DSN/未开认证直接拒绝启动;
|
||||
// local 档带 DSN 是配置错误。此处与 config.TierProblems 同规则(纵深防御,
|
||||
// 服务入口不依赖 CLI 是否校验)。
|
||||
if msgs := TierRefusal(opt); len(msgs) > 0 {
|
||||
return fmt.Errorf("tier guard: %s", strings.Join(msgs, ";"))
|
||||
}
|
||||
if opt.Tier == "team" && !opt.DisableExecTools && opt.ExecBackend == "" {
|
||||
log.Printf("warning: team 档未配置执行隔离(disableExecTools 或 execBackend);向真实多用户开放前必须补齐(方案 KTD1)")
|
||||
}
|
||||
if opt.InsecureListen && !config.IsLoopbackListen(opt.Addr) && !resolved.AuthEnabled() {
|
||||
log.Printf("warning: listening on %s without authentication because insecureListen is set", opt.Addr)
|
||||
}
|
||||
traceFn := telemetry.StartFunc(logger)
|
||||
var st *store.Store
|
||||
var err error
|
||||
if opt.StorageDSN != "" {
|
||||
@@ -91,7 +179,20 @@ func Run(opt Options) error {
|
||||
if artifacts == "" {
|
||||
artifacts = filepath.Join(opt.DataDir, "artifacts")
|
||||
}
|
||||
st, err = store.OpenPostgres(opt.StorageDSN, artifacts)
|
||||
// 共享工件(U5/KTD9):配置了 S3 兼容对象存储时注入(多实例共享);
|
||||
// 未配置回落本地目录(单实例/共享卷)。凭据只经环境(不入库不入报告)。
|
||||
blobs, berr := blobstore.NewS3FromEnv()
|
||||
if berr != nil {
|
||||
return fmt.Errorf("blobstore s3: %w", berr)
|
||||
}
|
||||
if blobs != nil {
|
||||
log.Printf("artifacts: shared via S3-compatible object storage (prefix %q)", blobs.Prefix)
|
||||
}
|
||||
st, err = store.OpenPostgresWithOptions(opt.StorageDSN, artifacts, opt.PGPool, blobs)
|
||||
if err != nil && opt.Tier == "team" {
|
||||
// team 档拒绝就绪(R2):PG 故障不回落 SQLite,错误必须明说。
|
||||
return fmt.Errorf("team 档打开 PostgreSQL 失败,拒绝就绪(不回落 SQLite): %w", err)
|
||||
}
|
||||
} else {
|
||||
st, err = store.OpenSQLite(filepath.Join(opt.DataDir, "agent-system.db"))
|
||||
}
|
||||
@@ -99,7 +200,30 @@ func Run(opt Options) error {
|
||||
return fmt.Errorf("open store: %w", err)
|
||||
}
|
||||
defer st.Close()
|
||||
log.Printf("storage tier: %s", st.Dialect())
|
||||
// U1 I3:svc 注入的服务实例身份(manifest.instance_id)与库内 marker 对齐;
|
||||
// 未设置则跳过(兼容未接线旧调用方)。与 --instance(runs 进程标识)无关。
|
||||
if sysID := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")); sysID != "" {
|
||||
dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF"))
|
||||
if dbref == "" {
|
||||
if opt.StorageDSN != "" {
|
||||
dbref = "pg"
|
||||
} else {
|
||||
dbref = "sqlite"
|
||||
}
|
||||
}
|
||||
if err := st.VerifySystemIdentity(sysID, dbref); err != nil {
|
||||
return fmt.Errorf("system identity: %w", err)
|
||||
}
|
||||
}
|
||||
pool := st.Pool()
|
||||
if versions, verr := st.MigrationStatus(); verr == nil {
|
||||
log.Printf("storage tier: %s (dialect=%s pool=%d/%d idleSec=%d lifetimeSec=%d schema=v%d)",
|
||||
tierName(opt), st.Dialect(), pool.MaxOpenConn, pool.MaxIdleConn, pool.ConnMaxIdleTimeSec,
|
||||
pool.ConnMaxLifetimeSec, latestVersion(versions))
|
||||
} else {
|
||||
log.Printf("storage tier: %s (dialect=%s pool=%d/%d schema=? migration status unavailable: %v)",
|
||||
tierName(opt), st.Dialect(), pool.MaxOpenConn, pool.MaxIdleConn, verr)
|
||||
}
|
||||
|
||||
b := bus.New()
|
||||
reg := tools.NewRegistry()
|
||||
@@ -112,7 +236,33 @@ func Run(opt Options) error {
|
||||
}
|
||||
// 执行原语(agent-native 交付):shell/python 在会话工作区执行 + 产物登记桥,
|
||||
// runtime.exec=false 可整体禁用(fail-closed)。生成文档/表格/演示稿的方法
|
||||
// 在提示词库 base.deliverables,不在系统工具里。
|
||||
// 在提示词库 base.deliverables,不在系统工具里。启动参数 DisableExecTools
|
||||
// 为不受用户设置影响的硬关闭(多用户部署的默认安全档)。
|
||||
tools.SetExecDisabled(opt.DisableExecTools)
|
||||
if opt.DisableExecTools {
|
||||
log.Printf("exec tools: disabled by startup option (runtime.exec settings are ignored)")
|
||||
}
|
||||
// EXT-16:执行后端选择(direct|sandbox);未知名字启动即失败(fail-closed)。
|
||||
if err := tools.SetExecBackend(opt.ExecBackend); err != nil {
|
||||
return fmt.Errorf("exec backend: %w", err)
|
||||
}
|
||||
if tools.ExecBackendName() == "sandbox" {
|
||||
log.Printf("exec tools: backend=sandbox (bwrap: no network, ro root, session-only writes, proc limit)")
|
||||
}
|
||||
if opt.ExecDepsDir != "" {
|
||||
tools.SetExecDepsDir(opt.ExecDepsDir)
|
||||
log.Printf("exec tools: sandbox deps layer=%s", opt.ExecDepsDir)
|
||||
}
|
||||
if opt.ExecWorkspace == "overlay" {
|
||||
tools.SetWorkspaceOverlay(true)
|
||||
if tools.ExecBackendName() == "sandbox" {
|
||||
log.Printf("exec tools: workspace=overlay (frozen base + private upper + CAS apply)")
|
||||
} else {
|
||||
log.Printf("exec tools: workspace=overlay ignored (backend is not sandbox)")
|
||||
}
|
||||
} else if opt.ExecWorkspace != "" {
|
||||
return fmt.Errorf("exec workspace: unknown mode %q (overlay)", opt.ExecWorkspace)
|
||||
}
|
||||
if err := tools.RegisterExecTools(reg); err != nil {
|
||||
return fmt.Errorf("exec tools: %w", err)
|
||||
}
|
||||
@@ -123,6 +273,10 @@ func Run(opt Options) error {
|
||||
return fmt.Errorf("code orchestration tool: %w", err)
|
||||
}
|
||||
setSvc := settings.NewService(st)
|
||||
effBoot := setSvc.Effective(store.OwnerDefault, "")
|
||||
traceFn := telemetry.StartFunc(logger,
|
||||
telemetry.WithServiceName(effBoot.GetString("telemetry.serviceName", "agent-system-backend")),
|
||||
telemetry.WithEndpoint(effBoot.GetString("telemetry.otlpEndpoint", "")))
|
||||
setSvc.SetSystemOverride(map[string]any{"storage": map[string]any{"dataDir": opt.DataDir}})
|
||||
// 点路径键展开必须在读 tools.tavily.apiKeys 之前,否则启动仍注册旧池。
|
||||
norm, err := setSvc.NormalizeStored()
|
||||
@@ -142,23 +296,32 @@ func Run(opt Options) error {
|
||||
if owner == "" {
|
||||
owner = store.OwnerDefault
|
||||
}
|
||||
return st.ActiveSecret(owner, service)
|
||||
if v, ok := st.ActiveSecret(owner, service); ok {
|
||||
return v, true
|
||||
}
|
||||
// P2 受管:密钥统一迁入 secrets 表后,系统层受管密钥挂在系统行
|
||||
//(owner=settings.OwnerSystemRow);租户未单独配置时回退系统密钥。
|
||||
return st.ActiveSecret(settings.OwnerSystemRow, service)
|
||||
}
|
||||
rag.SecretLookup = func(service, fallback string) string {
|
||||
return runtime.LookupSecret(store.OwnerDefault, service, fallback)
|
||||
}
|
||||
// 网络搜索(Tavily):配置密钥池才注册(对齐 agent-native 语义:未配置不静默降级)。
|
||||
eff := setSvc.Effective(store.OwnerDefault, "")
|
||||
tavilyRaw := eff.GetString("tools.tavily.apiKeys", "")
|
||||
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
|
||||
tavilyRaw = active
|
||||
}
|
||||
if keys := splitKeyPool(tavilyRaw); len(keys) > 0 {
|
||||
provider := agenttools.NewTavilyProvider(eff.GetString("tools.tavily.endpoint", ""), keys)
|
||||
if err := tools.RegisterSearchTools(reg, provider); err != nil {
|
||||
return fmt.Errorf("search tools: %w", err)
|
||||
// 网络搜索(Tavily):始终注册,密钥按调用时读取(设置页/svc 播种改了
|
||||
// 即刻生效——此前启动时快照密钥,播种后必须重启工具才注册)。
|
||||
// 未配置时调用返回明确的「未配置」提示,不静默降级为空结果。
|
||||
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 {
|
||||
tavilyNow = active
|
||||
}
|
||||
log.Printf("tools: tavily_search/web_search registered (%d key(s))", len(keys))
|
||||
keys := splitKeyPool(tavilyNow)
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
return agenttools.NewTavilyProvider(effNow.GetString("tools.tavily.endpoint", ""), keys)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("search tools: %w", err)
|
||||
}
|
||||
promptLib, err := prompts.DefaultLibrary()
|
||||
if err != nil {
|
||||
@@ -255,6 +418,9 @@ func Run(opt Options) error {
|
||||
}
|
||||
}
|
||||
cfg.Trace = traceFn
|
||||
// N1 WP4:小队成员的内核 ask 决策经 GateOptions.PermissionAsk 到达
|
||||
// runCtx 的 HITL 确认卡(宿主无实现则 nil,内核保持 ask=拒绝旧行为)。
|
||||
cfg.PermissionAsk = runtime.PermissionAskOf(rc)
|
||||
if host, ok := rc.(interface {
|
||||
OpenCheckpoints() (*checkpoint.Log, error)
|
||||
CheckpointAnchor() string
|
||||
@@ -272,7 +438,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
|
||||
}
|
||||
}
|
||||
@@ -281,6 +450,9 @@ func Run(opt Options) error {
|
||||
subRunner := &runtime.SubLLMRunner{Prompts: promptSvc, Trace: traceFn}
|
||||
mockRunner := &runtime.MockRunner{}
|
||||
mgr := runmgr.New(st, b, reg, profSvc, setSvc, convRunner, subRunner, mockRunner, &runtime.MockSubagent{})
|
||||
authOn := resolved.AuthEnabled()
|
||||
effectiveAccount := accountEffectiveAuthorization(st, authOn)
|
||||
mgr.SetAccountEffectiveAuthorization(effectiveAccount, !authOn)
|
||||
instanceID := opt.InstanceID
|
||||
if instanceID == "" {
|
||||
host, err := os.Hostname()
|
||||
@@ -294,6 +466,8 @@ func Run(opt Options) error {
|
||||
if err := mgr.ReconcileInterrupted(); err != nil {
|
||||
return fmt.Errorf("reconcile interrupted runs: %w", err)
|
||||
}
|
||||
// 后台子代理通知重启重投(N2 WP5):未投递结果装回队列并按空闲语义投递。
|
||||
mgr.LoadPendingNotices()
|
||||
mgr.SetSquadRunner(&runtime.SquadRunner{})
|
||||
|
||||
// 意图闸门:插件意图 overlay → agent-intent 注册表(未装载意图 = 行为回退)。
|
||||
@@ -324,29 +498,167 @@ 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)
|
||||
pipe.SetEmbedder(rag.SwitchingEmbedder{HTTP: rag.NewHTTPEmbedder(pipe.EmbeddingRoute), Provider: func() string {
|
||||
embedder := rag.SwitchingEmbedder{HTTP: rag.NewHTTPEmbedder(pipe.EmbeddingRoute), Provider: func() string {
|
||||
return setSvc.Effective(store.OwnerDefault, "").GetString("models.embedding.provider", "")
|
||||
}})
|
||||
}}
|
||||
pipe.SetEmbedder(embedder)
|
||||
pipe.SetReranker(rag.NewHTTPReranker(pipe.RerankRoute))
|
||||
mgr.SetRetriever(pipe)
|
||||
mgr.SetIndexer(pipe)
|
||||
|
||||
// 意图慢通道(I2):语义向量裁决 + 轻量模型命令生成。
|
||||
// 两个开关都默认关——此时识别行为与 I1 完全一致(fail-closed 分层);
|
||||
// 开启语义需已配置 embedding 端点,开启生成需可用的轻量/主模型档。
|
||||
if setSvc.Effective(store.OwnerDefault, "").GetBool("runtime.intentSemantic", false) {
|
||||
gate.SetSemantic(intentsem.EmbeddingScorer{Provider: embedder}, 0)
|
||||
}
|
||||
if setSvc.Effective(store.OwnerDefault, "").GetBool("runtime.intentGenerator", false) {
|
||||
effNow := setSvc.Effective(store.OwnerDefault, "")
|
||||
light := runtime.ResolveLightweightModel(core.AgentProfile{}, effNow)
|
||||
client := &agentllm.Client{BaseURL: light.Base, APIKey: light.Key, HTTPClient: &http.Client{}}
|
||||
gate.SetGenerator(intentsem.LightGenerator{Stream: client.Stream, Model: light.Model})
|
||||
}
|
||||
// fs 预留能力沙箱根:<DataDir>/workspaces/<owner>/<conversationID>/。
|
||||
mgr.SetWorkspaceRoot(filepath.Join(opt.DataDir, "workspaces"))
|
||||
// 中断子代理续跑(N2 WP5):依赖转录目录(workspaceRoot)与派生请求。
|
||||
mgr.ResumeInterruptedChildrenChecked(context.Background(), func(ctx context.Context, owner string) error {
|
||||
valid, err := effectiveAccount(ctx, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !valid {
|
||||
return store.ErrAccountForbidden
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// 收件箱任务卡:事件走会话事件管道(持久化/seq/重放);关闭即 T0 基线。
|
||||
cardSvc := cardsvc.New(st, mgr.EmitCardEvent)
|
||||
cardSvc.SetAccountCheckedEmitter(mgr.EmitCardEventAccountChecked)
|
||||
cardSvc.SetProjectSharedEmitter(mgr.EmitProjectSharedCardEvent)
|
||||
surfaceSvc := surfacesvc.New(st, mgr.EmitCardEvent)
|
||||
surfaceSvc.SetAccountCheckedEmitter(mgr.EmitCardEventAccountChecked)
|
||||
go func() { // 启动恢复:重发「快照已落盘、事件未发出」的待恢复投影(KTD)。
|
||||
if n, err := surfaceSvc.RecoverPendingEventsAccountChecked(context.Background(), !authOn); err == nil && n > 0 {
|
||||
log.Printf("surface: recovered %d pending events", n)
|
||||
}
|
||||
}()
|
||||
mgr.SetCards(cardSvc)
|
||||
// 执行身份与命令消费(协作存储 U5):PG 档启动跨实例命令轮询;
|
||||
// SQLite 本地档无跨实例命令(StartCommandConsumer 自行短路)。
|
||||
mgr.SetInstanceTag(instanceTag(opt), opt.Addr)
|
||||
stopCommands := mgr.StartCommandConsumer(context.Background())
|
||||
defer stopCommands()
|
||||
// 执行预算(协作存储 U3,R8):runtime.execBudgetGlobal / runtime.execBudgetPerUser
|
||||
// 全零=不限(本地档默认,行为不变);开启后执行许可共享账本生效,
|
||||
// 待执行队列上限 runtime.execQueueMax(默认 200,容量合同数值待 U7 实测调整)。
|
||||
mgr.SetExecutionBudgetFn(func() store.ExecutionBudget {
|
||||
eff := setSvc.Effective(store.OwnerDefault, "")
|
||||
return store.ExecutionBudget{
|
||||
Global: int(eff.GetNumber("runtime.execBudgetGlobal", 0)),
|
||||
PerUser: int(eff.GetNumber("runtime.execBudgetPerUser", 0)),
|
||||
}
|
||||
}, int(setSvc.Effective(store.OwnerDefault, "").GetNumber("runtime.execQueueMax", 200)))
|
||||
|
||||
// 命令总线(提案 T3/D27/D30):四入口同源分发 + 审计 + 危险内联确认卡。
|
||||
bus := cmdbus.New(st, cardSvc, func() time.Time { return time.Now().UTC() })
|
||||
bus.SetEffectiveAccountGuard(func(ctx context.Context, actor string) error {
|
||||
valid, err := effectiveAccount(ctx, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !valid {
|
||||
return store.ErrAccountForbidden
|
||||
}
|
||||
return nil
|
||||
})
|
||||
bus.RequireAccountCheckedWrites(!authOn)
|
||||
// U5:页面动作分发口——surfacesvc.SubmitAction 经命令总线四入口同源分发
|
||||
//(审计信封、参数校验、DangerHigh 内联确认卡全复用;Confirmed 不透传)。
|
||||
surfaceSvc.SetDispatcher(func(ctx context.Context, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) {
|
||||
resp := bus.Dispatch(ctx, cmdbus.Request{ID: d.CommandID, Params: d.Params,
|
||||
Entrance: "button", Owner: d.Owner, ConversationID: d.ConversationID})
|
||||
state := "committed"
|
||||
if resp.Receipt.State != "" {
|
||||
state = string(resp.Receipt.State)
|
||||
}
|
||||
cardID := ""
|
||||
if resp.Card != nil {
|
||||
cardID = string(resp.Card.ID)
|
||||
}
|
||||
return surfacesvc.ActionOutcome{State: state, CardID: cardID, Receipt: resp.Receipt, Payload: resp.Payload}, nil
|
||||
})
|
||||
// U3:generative-ui 工具注入闸——activation 台账关闭时命令不投影给该 owner
|
||||
// 的模型(禁用即不注入能力;执行端守卫仍在 surfacesvc)。
|
||||
bus.SetToolGate(func(owner, commandID string) bool {
|
||||
if !strings.HasPrefix(commandID, genui.PluginID+".") {
|
||||
return true
|
||||
}
|
||||
row, err := surfaceSvc.Activation(context.Background(), owner, genui.PluginID)
|
||||
return err == nil && row.Enabled
|
||||
})
|
||||
// 自动建议页面 opt-in:settings ∧ activation;预算/冷却在 genui.Auto。
|
||||
mgr.SetGenUIAuto(genui.NewAuto(
|
||||
func(owner string) bool {
|
||||
row, err := surfaceSvc.Activation(context.Background(), owner, genui.PluginID)
|
||||
return err == nil && row.Enabled
|
||||
},
|
||||
func(owner string) bool {
|
||||
return setSvc.Effective(owner, "").GetBool(genui.SettingAutoPresent, false)
|
||||
},
|
||||
func(owner, kind, detail string) {
|
||||
_ = st.AppendAuditEvent(store.AuditEvent{Owner: owner, Action: kind, Evidence: detail})
|
||||
},
|
||||
))
|
||||
|
||||
bus.SetPendingStore(st)
|
||||
pluginHost.OnCommands = bus.RegisterPluginCommands
|
||||
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
|
||||
caps := hostcaps{st: st, pipe: pipe, set: setSvc}
|
||||
applyCompiled(skillsRoot, bus, caps)
|
||||
authzSlot := &projectAuthz{}
|
||||
projectSlot := &projectLifecycleSlot{}
|
||||
directorySlot, err := newAccountDirectorySlot(st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
caps := hostcaps{projects: projectSlot, directory: directorySlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc, allowImplicitLocal: !authOn,
|
||||
accountGuard: func(owner string) error {
|
||||
valid, err := effectiveAccount(context.Background(), owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !valid {
|
||||
return store.ErrAccountForbidden
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
// EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面
|
||||
// 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。
|
||||
mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
|
||||
ok, err := authzSlot.Authorize(owner, projectID)
|
||||
return err == nil && ok
|
||||
})
|
||||
// EXT-14:命令即工具——带 exposeAsTool 的命令投影进回合工具清单,模型调用
|
||||
// 经命令总分发(Entrance=agent,同审计信封与高危内联确认卡)。
|
||||
mgr.SetCommandSource(busAdapter{bus: bus})
|
||||
// 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)
|
||||
@@ -360,6 +672,9 @@ func Run(opt Options) error {
|
||||
}
|
||||
}
|
||||
hostSrv.Dispatch = func(ctx context.Context, pluginID, owner, commandID string, params map[string]any) error {
|
||||
if err := caps.accountGuard(owner); err != nil {
|
||||
return err
|
||||
}
|
||||
resp := bus.Dispatch(ctx, cmdbus.Request{ID: commandID, Params: params, Entrance: "api", Actor: pluginID, Owner: owner})
|
||||
if resp.Receipt.State == "rejected" || resp.Receipt.State == "unknown" {
|
||||
return fmt.Errorf("%s", resp.Receipt.Reason)
|
||||
@@ -369,14 +684,34 @@ func Run(opt Options) error {
|
||||
hostSrv.Search = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
|
||||
return caps.searchDefault(ctx, owner, text)
|
||||
}
|
||||
// EXT-8 余项:hostapi 检索面同样吃适配器共享(本人 + 放行的他人项目知识)。
|
||||
hostSrv.SharedSearch = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
|
||||
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)
|
||||
}
|
||||
// EXT-9:宿主可关闭上传自动索引(发布决定移交下游命令层)。
|
||||
srv.DisableUploadIndexing = opt.DisableUploadIndexing
|
||||
// EXT-12 最小开关:会话卡不镜像进项目公共流(cardSvc 在上方已建)。
|
||||
cardSvc.MirrorConvCards = !opt.DisableConvCardMirror
|
||||
|
||||
srv.Workflows = wfReg
|
||||
mgr.SetWorkflows(wfReg)
|
||||
srv.Cards = cardSvc
|
||||
srv.Surfaces = surfaceSvc
|
||||
// WP3-I4:编译插件摘要进 /api/plugins 管理清单(注册面即清单面)。
|
||||
srv.CompiledPlugins = compiledSummary
|
||||
srv.Bus = bus
|
||||
srv.Gate = gate
|
||||
srv.Prompts = promptSvc
|
||||
@@ -400,7 +735,6 @@ func Run(opt Options) error {
|
||||
return int(setSvc.Effective(store.OwnerDefault, "").GetNumber("runtime.rateLimitPerMin", 0))
|
||||
})
|
||||
// 认证策略(auto):PG 档默认开启(多用户面不能裸奔);嵌入式档默认关闭(本地单用户)。
|
||||
authOn := resolved.AuthEnabled()
|
||||
srv.Auth = server.NewAuthenticator(st, authOn)
|
||||
if authOn {
|
||||
log.Printf("auth: API key required (create with: agent-system apikey create --owner <name>)")
|
||||
@@ -445,48 +779,105 @@ 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()
|
||||
func assertLocalAccountOwner(st *store.Store, storageDSN, dataDir string) error {
|
||||
if storageDSN == "" {
|
||||
storageDSN = os.Getenv("AGENT_SYSTEM_DSN")
|
||||
}
|
||||
hash, err := store.HashPassword(password)
|
||||
if storageDSN != "" {
|
||||
var isOwner bool
|
||||
err := st.DB().QueryRow(`SELECT current_user = pg_get_userbyid(c.relowner) FROM pg_class c WHERE c.oid='users'::regclass`).Scan(&isOwner)
|
||||
if err != nil || !isOwner {
|
||||
return store.ErrAccountForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(dataDir, "agent-system.db"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
return err
|
||||
}
|
||||
if err := withStore(storageDSN, dataDir, func(st *store.Store) error {
|
||||
return st.CreateAccount(owner, name, hash, isAdmin)
|
||||
}); err != nil {
|
||||
return "", err
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok || (os.Geteuid() != 0 && stat.Uid != uint32(os.Geteuid())) {
|
||||
return store.ErrAccountForbidden
|
||||
}
|
||||
return password, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetUserPassword CLI 重置密码:must_change=1(首登强制改密),吊销全部会话。
|
||||
func ResetUserPassword(storageDSN, dataDir, owner, password string) (string, error) {
|
||||
if password == "" {
|
||||
password = server.RandomPassword()
|
||||
// CreateUserAccount is the local owner-only, audited pending-account path.
|
||||
// Password-bearing creation is rejected before storage is opened.
|
||||
func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, string, error) {
|
||||
if password != "" || generatePassword {
|
||||
return "", "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: local creation uses trusted activation only")
|
||||
}
|
||||
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 {
|
||||
var out store.AccountOutcome
|
||||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
st.DeleteUserSessions(owner, "")
|
||||
return nil
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return password, nil
|
||||
_, next, err := st.CreateLocalPendingAccountWithAudit(owner, name, isAdmin, accountInstanceID())
|
||||
out = next
|
||||
return err
|
||||
})
|
||||
return out.Plain, out.OperationID, err
|
||||
}
|
||||
|
||||
// IssueUserLoginKey is retained only as a rejecting compatibility entry.
|
||||
func IssueUserLoginKey(storageDSN, dataDir, owner, purpose string, openExisting, revokeAllCreds bool, ttl time.Duration) (string, error) {
|
||||
return "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: use owner initialization or owner-confirmed recovery")
|
||||
}
|
||||
|
||||
// InitializeAccount resumes a specific pending account. The operator identity
|
||||
// comes from the local OS UID; the target is fixed by stable user ID and CAS.
|
||||
func InitializeAccount(storageDSN, dataDir, userID string, expectedStateVersion, expectedGeneration int64) (string, string, error) {
|
||||
var plain, owner string
|
||||
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
|
||||
if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
account, err := st.GetAccount(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := st.InitializePendingAccountWithAudit(userID, expectedStateVersion, expectedGeneration, accountInstanceID())
|
||||
plain, owner = out.Plain, account.Owner
|
||||
return err
|
||||
})
|
||||
return plain, owner, 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 {
|
||||
if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
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) {
|
||||
return "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: administrators cannot set another user's password")
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -495,13 +886,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
|
||||
}
|
||||
@@ -509,55 +899,119 @@ 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 {
|
||||
if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
account, err := st.GetAccountByOwner(owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !account.Enabled || account.InitializationState == store.InitializationPending {
|
||||
return store.ErrAccountForbidden
|
||||
}
|
||||
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 不存在(先建立账号)", 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 withIdentityStore(storageDSN, dataDir, func(st *store.Store) error {
|
||||
return st.EnsureSystemIdentity(instanceID, dbref)
|
||||
})
|
||||
}
|
||||
|
||||
// VerifySystemIdentity CLI:校验库内服务身份(svc start 前;失败不插入)。
|
||||
// 只打开既有库,不建库、不迁移。schemaVersion 取身份行上的值。
|
||||
func VerifySystemIdentity(storageDSN, dataDir, instanceID, dbref string) (int, error) {
|
||||
var schemaVersion int
|
||||
err := withIdentityStore(storageDSN, dataDir, func(st *store.Store) error {
|
||||
if err := st.VerifySystemIdentity(instanceID, dbref); err != nil {
|
||||
return err
|
||||
}
|
||||
row, err := st.GetSystemIdentity()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
schemaVersion = row.SchemaVersion
|
||||
return nil
|
||||
})
|
||||
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 的整型缓存读(限流值热读取免打库)。
|
||||
@@ -632,3 +1086,31 @@ func splitKeyPool(s string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// busAdapter EXT-14:cmdbus.Bus 适配 runmgr.CommandSource。视图类型转换在此收口
|
||||
// (runmgr 不依赖 cmdbus,边不进架构基线);Entrance 由 runmgr 固定为 agent。
|
||||
type busAdapter struct{ bus *cmdbus.Bus }
|
||||
|
||||
func (a busAdapter) CommandTools() []runmgr.CommandTool {
|
||||
return a.CommandToolsFor("")
|
||||
}
|
||||
|
||||
func (a busAdapter) CommandToolsFor(owner string) []runmgr.CommandTool {
|
||||
src := a.bus.CommandToolsFor(owner)
|
||||
out := make([]runmgr.CommandTool, len(src))
|
||||
for i, ct := range src {
|
||||
out[i] = runmgr.CommandTool{ID: ct.ID, Description: ct.Description, Params: ct.Params, Risk: ct.Risk}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a busAdapter) DispatchCommand(ctx context.Context, req runmgr.CommandRequest) runmgr.CommandResponse {
|
||||
resp := a.bus.Dispatch(ctx, cmdbus.Request{
|
||||
ID: req.ID, Params: req.Params, Entrance: req.Entrance,
|
||||
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID,
|
||||
})
|
||||
return runmgr.CommandResponse{
|
||||
State: string(resp.Receipt.State), Reason: resp.Receipt.Reason,
|
||||
Payload: resp.Payload, Card: resp.Card != nil,
|
||||
}
|
||||
}
|
||||
|
||||
+159
-4
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
|
||||
agentcommand "agent-command"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/server"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
@@ -25,6 +27,29 @@ type pluginRegistry struct {
|
||||
compiledRegistry
|
||||
pluginID string
|
||||
caps hostcaps
|
||||
// summary 是本插件的注册面摘要(WP3-I4):AddAdminZone 声明记录于此,
|
||||
// server 经 CompiledPlugins 供给分区清单端点(WP3-I3)。
|
||||
summary *server.CompiledPlugin
|
||||
}
|
||||
|
||||
// AddAdminZone 记录管理区分区声明(WP3-I3):声明即展示元数据,权威记录
|
||||
// 就是注册期摘要;规格非法(非对象、id/title 缺失)fail-closed 拒绝,
|
||||
// 使 register() 整体中止(与命令注册同一失败语义)。
|
||||
func (r pluginRegistry) AddAdminZone(spec json.RawMessage) error {
|
||||
if r.summary == nil {
|
||||
return errors.New("extension: " + r.pluginID + ": admin zone outside applyCompiled")
|
||||
}
|
||||
var s extension.AdminZoneSpec
|
||||
if err := json.Unmarshal(spec, &s); err != nil {
|
||||
return fmt.Errorf("extension: admin zone spec: %w", err)
|
||||
}
|
||||
if s.ID == "" || s.Title == "" {
|
||||
return fmt.Errorf("extension: %s: admin zone requires id and title", r.pluginID)
|
||||
}
|
||||
r.summary.AdminZones = append(r.summary.AdminZones, server.AdminZoneSection{
|
||||
ID: s.ID, Title: s.Title, Icon: s.Icon, Desc: s.Desc, Order: s.Order,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error {
|
||||
@@ -39,14 +64,30 @@ func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Hand
|
||||
return fmt.Errorf("extension: command spec: %w", err)
|
||||
}
|
||||
return r.bus.RegisterPluginCommand(s, func(ctx context.Context, req cmdbus.Request) (any, error) {
|
||||
host := inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner}
|
||||
host := requestBoundHost{inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner, actor: req.Actor}}
|
||||
return h(ctx, host, extension.Request{
|
||||
ID: req.ID, Params: req.Params, Entrance: req.Entrance,
|
||||
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID,
|
||||
HostProjectID: req.HostProjectID,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// AddIntent 意图声明(G2-2):汇入总线意图表;插件身份随声明归因。
|
||||
func (r pluginRegistry) AddIntent(spec json.RawMessage) error {
|
||||
if r.bus == nil {
|
||||
return nil
|
||||
}
|
||||
var is extension.IntentSpec
|
||||
if err := json.Unmarshal(spec, &is); err != nil {
|
||||
return fmt.Errorf("extension: intent spec: %w", err)
|
||||
}
|
||||
return r.bus.Intents().Declare(cmdbus.Intent{
|
||||
ID: is.ID, Phrases: is.Phrases, Command: is.Command,
|
||||
Params: is.Params, Description: is.Description, Plugin: r.pluginID,
|
||||
})
|
||||
}
|
||||
|
||||
func (r compiledRegistry) AddSkill(name, markdown string) error {
|
||||
if r.skillsRoot == "" || name == "" {
|
||||
return nil
|
||||
@@ -65,22 +106,136 @@ func (r compiledRegistry) AddCommand(spec json.RawMessage) error {
|
||||
return r.bus.RegisterPluginCommands([]json.RawMessage{spec})
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
func (r compiledRegistry) RegisterAccountDirectoryScopeResolver(extension.AccountDirectoryScopeResolver) error {
|
||||
return errors.New("extension: declarative plugins cannot register account directory authorization")
|
||||
}
|
||||
|
||||
func (r pluginRegistry) RegisterAccountDirectoryScopeResolver(fn extension.AccountDirectoryScopeResolver) error {
|
||||
if r.pluginID != "rdm" || r.caps.directory == nil {
|
||||
return errors.New("extension: account directory resolver requires trusted RDM registration")
|
||||
}
|
||||
return r.caps.directory.register(r.pluginID, fn)
|
||||
}
|
||||
|
||||
// RegisterResourceAuthorizer 进程内扩展(EXT-1):填充 EXT-8 适配器槽
|
||||
// (hostcaps.authz 共享指针;首个注册者生效,组合根已装配进 Server/runmgr)。
|
||||
// 此前 pluginRegistry 未覆盖该方法——进程内插件注册适配器会命中声明式分支的
|
||||
// 拒绝,register() 整体中止,插件命令全部未注册(RDM 二进制 rdm.* 全部
|
||||
// 「unknown command」;MS1 重验收烟测实测发现)。
|
||||
func (r pluginRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthorizer) error {
|
||||
if fn == nil {
|
||||
return errors.New("extension: " + r.pluginID + ": nil resource authorizer")
|
||||
}
|
||||
r.caps.authz.set(r.pluginID, fn)
|
||||
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
|
||||
}
|
||||
|
||||
func applyCompiled(skillsRoot string, bus *cmdbus.Bus, caps hostcaps) {
|
||||
// recordRegistry 包装 per-plugin 注册表,把成功注册的声明资产名记入摘要
|
||||
// (WP3-I4:注册面即清单面——编译插件的管理条目不另设声明口)。
|
||||
type recordRegistry struct {
|
||||
extension.Registry
|
||||
sum *server.CompiledPlugin
|
||||
}
|
||||
|
||||
func (r recordRegistry) RegisterAccountDirectoryScopeResolver(fn extension.AccountDirectoryScopeResolver) error {
|
||||
registrar, ok := r.Registry.(extension.AccountDirectoryRegistrar)
|
||||
if !ok {
|
||||
return errors.New("extension: account directory registration unavailable")
|
||||
}
|
||||
return registrar.RegisterAccountDirectoryScopeResolver(fn)
|
||||
}
|
||||
|
||||
func (r recordRegistry) AddCommand(spec json.RawMessage) error {
|
||||
if err := r.Registry.AddCommand(spec); err != nil {
|
||||
return err
|
||||
}
|
||||
if id := specID(spec); id != "" {
|
||||
r.sum.Commands = append(r.sum.Commands, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r recordRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error {
|
||||
if err := r.Registry.AddCommandHandler(spec, h); err != nil {
|
||||
return err
|
||||
}
|
||||
if id := specID(spec); id != "" {
|
||||
r.sum.Commands = append(r.sum.Commands, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r recordRegistry) AddIntent(spec json.RawMessage) error {
|
||||
if err := r.Registry.AddIntent(spec); err != nil {
|
||||
return err
|
||||
}
|
||||
if id := specID(spec); id != "" {
|
||||
r.sum.Intents = append(r.sum.Intents, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r recordRegistry) AddSkill(name, markdown string) error {
|
||||
if err := r.Registry.AddSkill(name, markdown); err != nil {
|
||||
return err
|
||||
}
|
||||
r.sum.Skills = append(r.sum.Skills, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// specID 宽松提取 spec 顶层 id:仅服务清单摘要展示,解析失败返回空串
|
||||
// 不记录,不影响注册结果本身。
|
||||
func specID(spec json.RawMessage) string {
|
||||
var s struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(spec, &s); err != nil {
|
||||
return ""
|
||||
}
|
||||
return s.ID
|
||||
}
|
||||
|
||||
func applyCompiled(skillsRoot string, bus *cmdbus.Bus, caps hostcaps) []server.CompiledPlugin {
|
||||
base := compiledRegistry{skillsRoot: skillsRoot, bus: bus}
|
||||
var out []server.CompiledPlugin
|
||||
for _, e := range extension.Compiled() {
|
||||
if e.Register == nil {
|
||||
continue
|
||||
}
|
||||
reg := pluginRegistry{compiledRegistry: base, pluginID: e.ID, caps: caps}
|
||||
if err := e.Register(reg); err != nil {
|
||||
sum := server.CompiledPlugin{ID: e.ID}
|
||||
reg := pluginRegistry{compiledRegistry: base, pluginID: e.ID, caps: caps, summary: &sum}
|
||||
// Register 失败的插件保留已成功注册部分(与命令总线一致:部分注册
|
||||
// 生效),摘要如实反映实际注册面。
|
||||
if err := e.Register(recordRegistry{Registry: reg, sum: &sum}); err != nil {
|
||||
log.Printf("extension: %s: %v", e.ID, err)
|
||||
}
|
||||
out = append(out, sum)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeMCPServers(raw string, extra []core.PluginMCPServer) string {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// fakeExtAuthz 进程内扩展:注册 EXT-8 适配器+一条命令——修复前适配器注册被
|
||||
// 声明式分支拒绝,register() 中止导致命令全部丢失。
|
||||
type fakeExtAuthz struct{}
|
||||
|
||||
func (f *fakeExtAuthz) Register(reg extension.Registry) error {
|
||||
if err := reg.RegisterResourceAuthorizer(func(owner string, projectID core.ID) (bool, error) {
|
||||
return owner == "alice", nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return reg.AddCommandHandler([]byte(`{"id":"t.authz.ping","title":"t"}`),
|
||||
func(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestInprocPluginRegistersAuthorizerAndCommands(t *testing.T) {
|
||||
extension.ResetCompiled()
|
||||
extension.RegisterCompiled(extension.InProc{ID: "t", Register: (&fakeExtAuthz{}).Register})
|
||||
defer extension.ResetCompiled()
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||||
caps := hostcaps{st: st, authz: &projectAuthz{}}
|
||||
applyCompiled("", bus, caps)
|
||||
// ① 命令已注册且可分发(修复前:适配器注册被拒 → register() 中止 →
|
||||
// 命令全部丢失,rdm.* 全部 unknown command)。
|
||||
if resp := bus.Dispatch(context.Background(), cmdbus.Request{
|
||||
ID: "t.authz.ping", Entrance: "button", Owner: "alice"}); resp.Receipt.State != "committed" {
|
||||
t.Fatalf("命令分发 = %+v(注册整体中止)", resp.Receipt)
|
||||
}
|
||||
// ② 适配器经共享槽生效:alice 放行、bob 拒绝。
|
||||
if ok, _ := caps.authz.Authorize("alice", "p1"); !ok {
|
||||
t.Fatal("alice 应放行")
|
||||
}
|
||||
if ok, _ := caps.authz.Authorize("bob", "p1"); ok {
|
||||
t.Fatal("bob 应被拒绝")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
)
|
||||
|
||||
// WP3-I4:applyCompiled 的注册面摘要——成功注册的命令/意图/技能记入
|
||||
// CompiledPlugin,失败注册不记录,Register 整体失败保留已成功部分。
|
||||
func TestApplyCompiledRecordsSummary(t *testing.T) {
|
||||
extension.ResetCompiled()
|
||||
t.Cleanup(extension.ResetCompiled)
|
||||
extension.RegisterCompiled(extension.InProc{ID: "demo", Register: func(reg extension.Registry) error {
|
||||
if err := reg.AddCommandHandler(json.RawMessage(`{"id":"demo.one","title":"一"}`), func(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
|
||||
return "ok", nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
// 重复 id 注册失败:总线拒绝,摘要不记录。
|
||||
if err := reg.AddCommand(json.RawMessage(`{"id":"demo.one","title":"重复"}`)); err == nil {
|
||||
t.Fatal("duplicate command should fail")
|
||||
}
|
||||
if err := reg.AddIntent(json.RawMessage(`{"id":"demo.go","phrases":["演示"],"command":"demo.one"}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = reg.AddSkill("demo-skill", "# 演示\n技能正文")
|
||||
// WP3-I3:管理区分区声明记入摘要;缺 id/title 的非法规格 fail-closed。
|
||||
if err := reg.AddAdminZone(json.RawMessage(`{"id":"demo.audit","title":"演示审计","icon":"search","order":20}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := reg.AddAdminZone(json.RawMessage(`{"title":"缺 id"}`)); err == nil {
|
||||
t.Fatal("admin zone without id should fail")
|
||||
}
|
||||
return nil
|
||||
}})
|
||||
extension.RegisterCompiled(extension.InProc{ID: "broken", Register: func(reg extension.Registry) error {
|
||||
if err := reg.AddCommand(json.RawMessage(`{"id":"broken.cmd","title":"先成功"}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
return reg.AddCommandHandler(json.RawMessage(`not-json`), nil)
|
||||
}})
|
||||
// 无 Register 的登记不产生条目。
|
||||
extension.RegisterCompiled(extension.InProc{ID: "empty"})
|
||||
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||||
sums := applyCompiled(t.TempDir(), bus, hostcaps{st: st, set: settings.NewService(st)})
|
||||
if len(sums) != 2 {
|
||||
t.Fatalf("want 2 summaries, got %d: %+v", len(sums), sums)
|
||||
}
|
||||
if sums[0].ID != "demo" {
|
||||
t.Fatalf("sums[0] = %+v", sums[0])
|
||||
}
|
||||
if len(sums[0].Commands) != 1 || sums[0].Commands[0] != "demo.one" {
|
||||
t.Fatalf("demo commands = %v", sums[0].Commands)
|
||||
}
|
||||
if len(sums[0].Intents) != 1 || sums[0].Intents[0] != "demo.go" {
|
||||
t.Fatalf("demo intents = %v", sums[0].Intents)
|
||||
}
|
||||
if len(sums[0].Skills) != 1 || sums[0].Skills[0] != "demo-skill" {
|
||||
t.Fatalf("demo skills = %v", sums[0].Skills)
|
||||
}
|
||||
// WP3-I3:合法声明记入,非法规格(缺 id)被拒后摘要不受污染。
|
||||
if len(sums[0].AdminZones) != 1 {
|
||||
t.Fatalf("demo admin zones = %+v", sums[0].AdminZones)
|
||||
}
|
||||
if z := sums[0].AdminZones[0]; z.ID != "demo.audit" || z.Title != "演示审计" || z.Icon != "search" || z.Order != 20 {
|
||||
t.Fatalf("demo admin zone = %+v", z)
|
||||
}
|
||||
// broken:Register 报错但先成功的命令仍在总线,摘要如实保留。
|
||||
if sums[1].ID != "broken" || len(sums[1].Commands) != 1 || sums[1].Commands[0] != "broken.cmd" {
|
||||
t.Fatalf("broken summary = %+v", sums[1])
|
||||
}
|
||||
// 摘要与总线一致:记录的命令真实可分发(不是空头衔)。
|
||||
resp := bus.Dispatch(context.Background(), cmdbus.Request{ID: "demo.one", Owner: "alice", Entrance: "test"})
|
||||
if resp.Receipt.State != agentcommand.StateCommitted {
|
||||
t.Fatalf("demo.one dispatch = %s (%s)", resp.Receipt.State, resp.Receipt.Reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// requestBoundHost is created only for a current lifecycle or command request.
|
||||
// A reconstructed global ScopedHost is a plain inprocHost and cannot expose
|
||||
// the delegated projection method by type assertion.
|
||||
type requestBoundHost struct{ inprocHost }
|
||||
|
||||
// ProjectProjectionForCreator only derives from a live request Host of the
|
||||
// registered RDM provider. The operator is inherited from that Host, never
|
||||
// accepted as a caller-supplied field. RDM's project.settings decision remains
|
||||
// its own domain authority; this capability fences the Backend projection.
|
||||
func (bound requestBoundHost) ProjectProjectionForCreator(creator, projectID string) (extension.ProjectHost, error) {
|
||||
h := bound.inprocHost
|
||||
if h.actor == "" || h.actor != h.owner || h.pluginID != "rdm" ||
|
||||
h.caps.projects == nil || h.caps.st == nil || creator == "" || projectID == "" {
|
||||
return nil, store.ErrAccountForbidden
|
||||
}
|
||||
slot := h.caps.projects
|
||||
slot.mu.Lock()
|
||||
registered := slot.provider != nil && slot.holder == h.pluginID
|
||||
slot.mu.Unlock()
|
||||
if !registered {
|
||||
return nil, store.ErrAccountForbidden
|
||||
}
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.caps.accountGuard != nil {
|
||||
if err := h.caps.accountGuard(creator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
project, err := h.caps.st.ProjectMetadata(projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if string(project.Owner) != creator || string(project.ID) != projectID {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
return delegatedProjectProjection{caps: h.caps, pluginID: h.pluginID,
|
||||
actor: h.actor, creator: creator, projectID: projectID}, nil
|
||||
}
|
||||
|
||||
// delegatedProjectProjection exposes only ProjectHost, and every method is
|
||||
// confined to the bound project. It cannot be cast to the broader Host.
|
||||
type delegatedProjectProjection struct {
|
||||
caps hostcaps
|
||||
pluginID string
|
||||
actor string
|
||||
creator string
|
||||
projectID string
|
||||
}
|
||||
|
||||
var _ extension.ProjectHost = delegatedProjectProjection{}
|
||||
|
||||
func (p delegatedProjectProjection) Owner() string { return p.creator }
|
||||
|
||||
func (p delegatedProjectProjection) check() error {
|
||||
if p.caps.accountGuard == nil {
|
||||
return nil
|
||||
}
|
||||
if err := p.caps.accountGuard(p.actor); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.caps.accountGuard(p.creator)
|
||||
}
|
||||
|
||||
func (p delegatedProjectProjection) target(id string) error {
|
||||
if id == "" || id != p.projectID {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p delegatedProjectProjection) OwnedProjects() ([]core.Project, error) {
|
||||
return nil, store.ErrAccountForbidden
|
||||
}
|
||||
|
||||
func (p delegatedProjectProjection) OwnedProject(string) (core.Project, error) {
|
||||
return core.Project{}, store.ErrAccountForbidden
|
||||
}
|
||||
|
||||
func (p delegatedProjectProjection) EnsureProject(string, string) (core.Project, error) {
|
||||
return core.Project{}, store.ErrAccountForbidden
|
||||
}
|
||||
|
||||
func (p delegatedProjectProjection) ProjectMetadata(id string) (core.Project, error) {
|
||||
if err := p.target(id); err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
if err := p.check(); err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
view := inprocHost{caps: p.caps, pluginID: p.pluginID, owner: p.creator, actor: p.actor}
|
||||
return view.ProjectMetadata(id)
|
||||
}
|
||||
|
||||
func (p delegatedProjectProjection) ProjectNameAvailable(name, exceptID string) (bool, error) {
|
||||
if err := p.target(exceptID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := p.check(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return p.caps.st.ProjectNameAvailable(name, exceptID)
|
||||
}
|
||||
|
||||
func (p delegatedProjectProjection) RenameOwnedProject(id, name string) (core.Project, error) {
|
||||
if err := p.target(id); err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
if err := p.check(); err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
return p.caps.st.RenameOwnedProjectAccountChecked(p.actor, p.creator, p.projectID, name, p.caps.allowImplicitLocal)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentcommand "agent-command"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/server"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func TestDelegatedProjectionIsBoundAtHTTPAndCommandEntries(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/entries.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
for _, owner := range []string{"alice", "bob"} {
|
||||
if err := st.EnsureUser(owner, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
httpProject, err := st.CreateProject("alice", "HTTP original")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
commandProject, err := st.CreateProject("alice", "Command original")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check := func(owner string) error {
|
||||
state, err := st.GetUserAuthState(owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !state.Enabled {
|
||||
return store.ErrAccountForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
projectFromRequest := func(h extension.Host, id, name string) (core.Project, error) {
|
||||
bound, ok := h.(extension.DelegatedProjectProjectionHost)
|
||||
if !ok {
|
||||
return core.Project{}, errors.New("request Host lost operator-bound projection")
|
||||
}
|
||||
projection, err := bound.ProjectProjectionForCreator("alice", id)
|
||||
if err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
return projection.RenameOwnedProject(id, name)
|
||||
}
|
||||
slot := &projectLifecycleSlot{}
|
||||
caps := hostcaps{st: st, set: settings.NewService(st), projects: slot, accountGuard: check}
|
||||
provider := completeLifecycle()
|
||||
provider.Rename = func(_ context.Context, h extension.Host, id, name string) (core.Project, error) {
|
||||
return projectFromRequest(h, id, name)
|
||||
}
|
||||
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := server.New(st, nil, nil, nil, caps.set, nil)
|
||||
srv.Auth = server.NewAuthenticator(st, true)
|
||||
slot.wire(srv, caps)
|
||||
key, err := st.CreateAPIKey("bob", "delegate-entry")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/projects/"+string(httpProject.ID), strings.NewReader(`{"name":"via HTTP"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("HTTP lifecycle rename: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
bus := cmdbus.New(st, nil, nil)
|
||||
bus.SetEffectiveAccountGuard(func(_ context.Context, owner string) error { return check(owner) })
|
||||
bus.RequireAccountCheckedWrites(false)
|
||||
reg := pluginRegistry{pluginID: "rdm", caps: caps, compiledRegistry: compiledRegistry{bus: bus}}
|
||||
err = reg.AddCommandHandler(json.RawMessage(`{"id":"rdm.fixture.rename","title":"Rename"}`),
|
||||
func(_ context.Context, h extension.Host, req extension.Request) (any, error) {
|
||||
id, _ := req.Params["id"].(string)
|
||||
name, _ := req.Params["name"].(string)
|
||||
return projectFromRequest(h, id, name)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := bus.Dispatch(context.Background(), cmdbus.Request{ID: "rdm.fixture.rename", Owner: "bob", Actor: "bob",
|
||||
Params: map[string]any{"id": string(commandProject.ID), "name": "via command"}})
|
||||
if result.Receipt.State != agentcommand.StateCommitted {
|
||||
t.Fatalf("command rename: %+v", result.Receipt)
|
||||
}
|
||||
for _, tc := range []struct{ id, want string }{{string(httpProject.ID), "via HTTP"}, {string(commandProject.ID), "via command"}} {
|
||||
project, err := st.ProjectMetadata(tc.id)
|
||||
if err != nil || project.Name != tc.want {
|
||||
t.Fatalf("entry %s did not reach bound projection: %+v %v", tc.id, project, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// Two processes share one isolated PostgreSQL schema. The child has already
|
||||
// accepted a stale early account read when the parent commits disable; only
|
||||
// the final Backend transaction can stop the physical rename.
|
||||
func TestPGDelegatedProjectionDisableAcrossProcesses(t *testing.T) {
|
||||
baseDSN := os.Getenv("AGENT_PG_TEST_DSN")
|
||||
if baseDSN == "" {
|
||||
t.Skip("isolated PostgreSQL DSN required")
|
||||
}
|
||||
separator := "?"
|
||||
if strings.Contains(baseDSN, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
if os.Getenv("IAM_U3_DELEGATE_CHILD") == "1" {
|
||||
dsn := baseDSN + separator + "search_path=" + os.Getenv("IAM_U3_DELEGATE_SCHEMA") + ",public"
|
||||
st, err := store.OpenExistingPostgres(dsn, filepath.Join(t.TempDir(), "artifacts"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
var bobChecks atomic.Int32
|
||||
check := func(owner string) error {
|
||||
state, err := st.GetUserAuthState(owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !state.Enabled {
|
||||
return store.ErrAccountForbidden
|
||||
}
|
||||
if owner == "bob" && bobChecks.Add(1) == 2 {
|
||||
if err := os.WriteFile(os.Getenv("IAM_U3_DELEGATE_READY"), []byte("ready"), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if _, err := os.Stat(os.Getenv("IAM_U3_DELEGATE_RELEASE")); err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return errors.New("parent did not release paused child")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
slot := &projectLifecycleSlot{}
|
||||
caps := hostcaps{st: st, projects: slot, accountGuard: check}
|
||||
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host := requestBoundHost{inprocHost{caps: caps, pluginID: "rdm", owner: "bob", actor: "bob"}}
|
||||
projection, err := host.ProjectProjectionForCreator("alice", os.Getenv("IAM_U3_DELEGATE_PROJECT"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := projection.RenameOwnedProject(os.Getenv("IAM_U3_DELEGATE_PROJECT"), "late"); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("late physical rename was not fenced: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
st := openDirectoryTestStore(t)
|
||||
if err := st.EnsureSystemIdentity("u3-delegate-two-process", "pg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, account := range []struct {
|
||||
owner string
|
||||
admin bool
|
||||
}{{"admin", true}, {"alice", false}, {"bob", false}} {
|
||||
if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
project, err := st.CreateProject("alice", "before")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var schema string
|
||||
if err := st.DB().QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
barrier := t.TempDir()
|
||||
ready, release := filepath.Join(barrier, "ready"), filepath.Join(barrier, "release")
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestPGDelegatedProjectionDisableAcrossProcesses$")
|
||||
cmd.Env = append(os.Environ(), "IAM_U3_DELEGATE_CHILD=1", "IAM_U3_DELEGATE_SCHEMA="+schema,
|
||||
"IAM_U3_DELEGATE_PROJECT="+string(project.ID), "IAM_U3_DELEGATE_READY="+ready, "IAM_U3_DELEGATE_RELEASE="+release)
|
||||
var output bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &output, &output
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if _, err := os.Stat(ready); err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
_ = cmd.Process.Kill()
|
||||
t.Fatalf("child did not reach stale early check: %s", strings.ReplaceAll(output.String(), baseDSN, "[REDACTED]"))
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
bob, err := st.GetAccountByOwner("bob")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(release, []byte("release"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("child failed: %v: %s", err, strings.ReplaceAll(output.String(), baseDSN, "[REDACTED]"))
|
||||
}
|
||||
got, err := st.ProjectMetadata(string(project.ID))
|
||||
if err != nil || got.Name != "before" {
|
||||
t.Fatalf("disabled operator's project rename persisted: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package app
|
||||
|
||||
// EXT-13 红测(提案 2026-10-09-ext-g1-b1-b3 §1):进程内 Host.KnowledgeSearch
|
||||
// 的检索面在适配器放行后吃到他人项目的共享知识;未放行保持本人两路、零泄露。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/rag"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// newExt13Host EXT-13 装配:真实 store + rag 管线 + authz 槽 + inprocHost(bob 视角)。
|
||||
func newExt13Host(t *testing.T) (interface {
|
||||
KnowledgeSearch(ctx context.Context, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error)
|
||||
}, *projectAuthz) {
|
||||
t.Helper()
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
set := settings.NewService(st)
|
||||
pipe := rag.NewPipeline(st, set, nil, nil)
|
||||
authz := &projectAuthz{}
|
||||
h := inprocHost{caps: hostcaps{st: st, pipe: pipe, set: set, authz: authz}, pluginID: "rdm", owner: "bob"}
|
||||
// alice 已发布的项目知识(带 ProjectID;模拟发布状态机产物)。
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{Owner: "alice", ProjectID: "p-1",
|
||||
Source: "plugin:rdm", Ref: "ext13-1", Chunk: "EXT13共享独占令牌陈述"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h, authz
|
||||
}
|
||||
|
||||
func TestInprocHostKnowledgeSearchShared(t *testing.T) {
|
||||
h, authz := newExt13Host(t)
|
||||
ctx := context.Background()
|
||||
q := core.RetrievalQuery{Text: "EXT13共享独占令牌", TopK: 5}
|
||||
|
||||
if hits, err := h.KnowledgeSearch(ctx, "", q.Text, q.TopK); err != nil || len(hits) != 0 {
|
||||
t.Fatalf("before grant: hits=%d err=%v, want 0(未放行零泄露)", len(hits), err)
|
||||
}
|
||||
// 适配器放行 bob × p-1 → Host 检索面吃到 alice 的共享命中。
|
||||
// (槽为首个注册者生效——切换授权改闭包内变量,不重注册。)
|
||||
allowBob := true
|
||||
authz.set("rdm", func(owner string, projectID core.ID) (bool, error) {
|
||||
return allowBob && owner == "bob" && string(projectID) == "p-1", nil
|
||||
})
|
||||
hits, err := h.KnowledgeSearch(ctx, "", q.Text, q.TopK)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("after grant: host search got 0 shared hits, want ≥1(EXT-13)")
|
||||
}
|
||||
// 指定他人 ProjectID 时同样经适配器(发布边界在下游状态机)。
|
||||
if hits, err := h.KnowledgeSearch(ctx, "p-1", q.Text, q.TopK); err != nil || len(hits) == 0 {
|
||||
t.Fatalf("scoped to shared project: hits=%d err=%v, want ≥1", len(hits), err)
|
||||
}
|
||||
// 撤销(同一注册内翻转授权)→ 共享命中即停、零泄露。
|
||||
allowBob = false
|
||||
if hits, err := h.KnowledgeSearch(ctx, "", q.Text, q.TopK); err != nil || len(hits) != 0 {
|
||||
t.Fatalf("after revoke: hits=%d err=%v, want 0", len(hits), err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package app
|
||||
|
||||
// EXT-18 AdminProvider 正负例:admin 主体 → true;非 admin / 空 actor(scoped
|
||||
// host)→ false;store 查询失败 → false(fail-closed)。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func TestExt18AdminProviderPositive(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("boss", "Boss", "pw", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "boss", actor: "boss"}
|
||||
if !h.IsAdmin() {
|
||||
t.Fatal("admin 主体应返回 true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExt18AdminProviderNegative(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("user", "User", "pw", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "user", actor: "user"}
|
||||
if h.IsAdmin() {
|
||||
t.Fatal("非 admin 主体应返回 false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExt18AdminProviderEmptyActor(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("boss", "Boss", "pw", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "boss", actor: ""} // scoped host(无 dispatch)
|
||||
if h.IsAdmin() {
|
||||
t.Fatal("空 actor(非 dispatch 上下文)恒 false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExt18RequestHostProjectIDField(t *testing.T) {
|
||||
// 编译期断言:Request 有 HostProjectID 字段。
|
||||
_ = ""
|
||||
var r struct{ HostProjectID string }
|
||||
r.HostProjectID = "proj-1"
|
||||
if r.HostProjectID != "proj-1" {
|
||||
t.Fatal("字段赋值失败")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/rag"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// newExt9Host EXT-9 测试装配:真实 store + rag 管线 + inprocHost(无 embedder,
|
||||
// embed=false 走全文档路径)。
|
||||
func newExt9Host(t *testing.T) (interface {
|
||||
KnowledgePublish(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error
|
||||
KnowledgeDelete(ctx context.Context, sourcePrefix string) error
|
||||
}, *store.Store, *rag.Pipeline) {
|
||||
t.Helper()
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
if err := st.EnsureUser("alice", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set := settings.NewService(st)
|
||||
pipe := rag.NewPipeline(st, set, nil, nil)
|
||||
h := inprocHost{caps: hostcaps{st: st, pipe: pipe, set: set}, pluginID: "rdm", owner: "alice"}
|
||||
return h, st, pipe
|
||||
}
|
||||
|
||||
func searchAll(t *testing.T, pipe *rag.Pipeline, owner, text string) []core.RetrievalHit {
|
||||
t.Helper()
|
||||
hits, err := pipe.Retrieve(context.Background(), core.ID(owner), core.RetrievalQuery{Text: text, TopK: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("retrieve: %v", err)
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
// EXT-9 验收:publish 按 ref 幂等(重试不重复);delete 后检索即停且幂等;
|
||||
// manual 来源不经 host 可删(前缀强制 plugin:<自身>)。
|
||||
func TestKnowledgePublishIdempotentAndRevoke(t *testing.T) {
|
||||
h, st, pipe := newExt9Host(t)
|
||||
ctx := context.Background()
|
||||
docs := []core.KnowledgeDoc{
|
||||
{Ref: "batch-001", Chunk: "EXT9甲 独占令牌之一陈述"},
|
||||
{Ref: "batch-001", Chunk: "EXT9乙 独占令牌之二陈述"},
|
||||
}
|
||||
if err := h.KnowledgePublish(ctx, docs, false); err != nil {
|
||||
t.Fatalf("publish#1: %v", err)
|
||||
}
|
||||
if err := h.KnowledgePublish(ctx, docs, false); err != nil { // 重试幂等
|
||||
t.Fatalf("publish#2: %v", err)
|
||||
}
|
||||
hits := searchAll(t, pipe, "alice", "独占令牌")
|
||||
if len(hits) != 2 {
|
||||
t.Fatalf("after double publish: hits=%d, want 2(幂等不重复;got %+v)", len(hits), hits)
|
||||
}
|
||||
|
||||
if err := h.KnowledgeDelete(ctx, "plugin:rdm"); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if hits = searchAll(t, pipe, "alice", "独占令牌"); len(hits) != 0 {
|
||||
t.Fatalf("after delete: hits=%d, want 0(撤销即停)", len(hits))
|
||||
}
|
||||
if err := h.KnowledgeDelete(ctx, "plugin:rdm"); err != nil { // 幂等
|
||||
t.Fatalf("delete#2: %v", err)
|
||||
}
|
||||
|
||||
// manual 来源:host 前缀强制拒绝,且内容不受插件删除影响。
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{Owner: "alice", Source: "manual", Ref: "m-1", Chunk: "manual 手册独占词"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.KnowledgeDelete(ctx, "manual"); err == nil {
|
||||
t.Fatal("delete(manual) must be rejected: plugins may only revoke their own prefix")
|
||||
}
|
||||
if hits = searchAll(t, pipe, "alice", "手册独占词"); len(hits) != 1 {
|
||||
t.Fatalf("manual row must survive: hits=%d", len(hits))
|
||||
}
|
||||
}
|
||||
+411
-12
@@ -1,13 +1,19 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"agent-system-backend/pkg/extension"
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"agent-system-backend/internal/ingest"
|
||||
"agent-system-backend/internal/rag"
|
||||
"agent-system-backend/internal/runtime"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/surfacesvc"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
@@ -15,14 +21,127 @@ import (
|
||||
// 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内
|
||||
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
|
||||
type hostcaps struct {
|
||||
st *store.Store
|
||||
pipe *rag.Pipeline
|
||||
set *settings.Service
|
||||
projects *projectLifecycleSlot
|
||||
directory *accountDirectorySlot
|
||||
accountGuard func(string) error
|
||||
allowImplicitLocal bool
|
||||
st *store.Store
|
||||
pipe *rag.Pipeline
|
||||
set *settings.Service
|
||||
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
|
||||
surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝)
|
||||
}
|
||||
|
||||
// projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。
|
||||
type projectAuthz struct {
|
||||
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) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.fn != nil {
|
||||
return // 首个注册者生效;后来者忽略(调用方经日志可查)
|
||||
}
|
||||
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
|
||||
a.mu.Unlock()
|
||||
if fn == nil || owner == "" || projectID == "" {
|
||||
return false, nil
|
||||
}
|
||||
return fn(owner, projectID)
|
||||
}
|
||||
|
||||
// wireScopedHostFactory EXT-11:把「按 owner+pluginID 构造受限 Host」注入公开面
|
||||
// (extension.SetScopedHostFactory)。组合根在 hostcaps 就绪后调用;重建句柄与
|
||||
// 分发期同构(owner 作用域、来源前缀强制、KV/任务台账隔离)。
|
||||
func wireScopedHostFactory(caps hostcaps) {
|
||||
extension.SetScopedHostFactory(func(owner, pluginID string) extension.Host {
|
||||
return inprocHost{caps: caps, pluginID: pluginID, owner: owner}
|
||||
})
|
||||
}
|
||||
|
||||
// search owner 作用域混合检索;projectID/topK 为空/0 时用管线默认值。
|
||||
// sharedRetriever EXT-13:rag.Pipeline.RetrieveShared 形状(避免 app 直接 import rag
|
||||
// 的具体类型断言散落)。
|
||||
type sharedRetriever interface {
|
||||
RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error)
|
||||
}
|
||||
|
||||
func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) {
|
||||
return c.pipe.Retrieve(ctx, core.ID(owner), core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK})
|
||||
if c.accountGuard != nil {
|
||||
if err := c.accountGuard(owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
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 {
|
||||
if rs, ok := interface{}(c.pipe).(sharedRetriever); ok {
|
||||
return rs.RetrieveShared(ctx, core.ID(owner), q, func(docOwner, pid core.ID) bool {
|
||||
ok, err := c.authz.Authorize(owner, pid)
|
||||
return err == nil && ok
|
||||
})
|
||||
}
|
||||
}
|
||||
return c.pipe.Retrieve(ctx, core.ID(owner), q)
|
||||
}
|
||||
|
||||
// searchDefault 与 hostapi /host/v1 检索端点同口径(不带 projectID/topK)。
|
||||
@@ -32,51 +151,219 @@ func (c hostcaps) searchDefault(ctx context.Context, owner, text string) ([]core
|
||||
|
||||
// complete 经基座 LLM 路由补全(owner 的模型配置生效;未配置报错)。
|
||||
func (c hostcaps) complete(ctx context.Context, owner, prompt string) (string, error) {
|
||||
if c.accountGuard != nil {
|
||||
if err := c.accountGuard(owner); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
route := runtime.ResolveChatModel(core.AgentProfile{}, c.set.Effective(owner, ""))
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return "", fmt.Errorf("llm route not configured")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if c.accountGuard != nil {
|
||||
if err := c.accountGuard(owner); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return completeChat(ctx, route.Base, route.Key, route.Model, prompt)
|
||||
}
|
||||
|
||||
// maxParseInput 与上传端点的读取上限同口径(server.upload 的 100<<20)。
|
||||
const maxParseInput = 100 << 20
|
||||
|
||||
// parse 走基座内置解析器(EXT-5):纯计算、不落 owner 数据;不可解析由
|
||||
// Warnings 显式表达,只有取消与超限是 error。
|
||||
func (c hostcaps) parse(ctx context.Context, filename, mime string, content []byte) (core.ParsedDocument, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return core.ParsedDocument{}, err
|
||||
}
|
||||
if len(content) > maxParseInput {
|
||||
return core.ParsedDocument{}, fmt.Errorf("parse input %d bytes exceeds %d limit", len(content), maxParseInput)
|
||||
}
|
||||
return ingest.Parse(filename, mime, content), nil
|
||||
}
|
||||
|
||||
// inprocHost 是进程内插件命令的 owner 作用域宿主句柄(extension.Host 实现)。
|
||||
// 每次分发按请求 Owner 新建;所有能力调用都以该 owner 为第一参数,插件无法越权。
|
||||
// actor 是本次分发主体(通常等于 owner);AdminProvider 能力经 actor 查询
|
||||
// (EXT-18)。非 dispatch 上下文(EXT-11 scoped host)actor 为空——IsAdmin 恒 false。
|
||||
type inprocHost struct {
|
||||
caps hostcaps
|
||||
pluginID string
|
||||
owner string
|
||||
actor string // EXT-18:分发主体(空=非 dispatch 上下文)
|
||||
}
|
||||
|
||||
func (h inprocHost) requireEffective() error {
|
||||
if h.caps.accountGuard != nil {
|
||||
return h.caps.accountGuard(h.owner)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h inprocHost) PluginID() string { return h.pluginID }
|
||||
func (h inprocHost) Owner() string { return h.owner }
|
||||
|
||||
// IsAdmin 实现 EXT-18 AdminProvider:查询本次分发主体的基座平台管理角色。
|
||||
// actor 为空(非 dispatch 上下文)恒 false;store 查询失败按非管理员处理
|
||||
// (fail-closed——不因存储故障放行超管面)。
|
||||
func (h inprocHost) IsAdmin() bool {
|
||||
if h.actor == "" {
|
||||
return false
|
||||
}
|
||||
state, err := h.caps.st.GetUserAuthState(h.actor)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return state.Enabled && state.IsAdmin
|
||||
}
|
||||
|
||||
func (h inprocHost) legacySystemDirectoryAuthorized() bool {
|
||||
if h.requireEffective() != nil {
|
||||
return false
|
||||
}
|
||||
if h.actor == "" {
|
||||
return false
|
||||
}
|
||||
state, err := h.caps.st.GetUserAuthState(h.actor)
|
||||
return err == nil && state.Enabled && state.IsAdmin
|
||||
}
|
||||
|
||||
// ListAccounts 保留 EXT-19 旧签名,仅供有受信分发主体的系统管理上下文;
|
||||
// 项目和文档候选必须走带 locator 的 AccountDirectoryProvider。
|
||||
func (h inprocHost) ListAccounts(cursor string, limit int) ([]extension.AccountEntry, string, error) {
|
||||
if !h.legacySystemDirectoryAuthorized() {
|
||||
return nil, "", store.ErrAccountForbidden
|
||||
}
|
||||
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: u.Enabled})
|
||||
}
|
||||
// 排序+游标(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 !h.legacySystemDirectoryAuthorized() {
|
||||
return nil, store.ErrAccountForbidden
|
||||
}
|
||||
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: u.Enabled})
|
||||
}
|
||||
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)
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.caps.st.PutPluginKVAccountChecked(h.owner, h.pluginID, key, value, h.caps.allowImplicitLocal)
|
||||
}
|
||||
|
||||
func (h inprocHost) KVGet(key string) (string, bool, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return h.caps.st.GetPluginKV(h.pluginID, h.owner, key)
|
||||
}
|
||||
|
||||
func (h inprocHost) Audit(action, target, evidence string) error {
|
||||
return h.caps.st.AppendAuditEvent(store.AuditEvent{
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.caps.st.AppendAuditEventAccountChecked(h.owner, store.AuditEvent{
|
||||
Owner: h.owner, Actor: "plugin:" + h.pluginID,
|
||||
Action: action, Target: target, Evidence: evidence,
|
||||
})
|
||||
}, h.caps.allowImplicitLocal)
|
||||
}
|
||||
|
||||
func (h inprocHost) Complete(ctx context.Context, prompt string) (string, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return h.caps.complete(ctx, h.owner, prompt)
|
||||
}
|
||||
|
||||
func (h inprocHost) KnowledgeSearch(ctx context.Context, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 失败降级纯全文,不阻断写入)。
|
||||
// (embedding 失败降级纯全文,不阻断写入)。
|
||||
func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(docs) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -86,16 +373,61 @@ 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...)
|
||||
return h.caps.st.AddKnowledgeAccountChecked(h.owner, h.caps.allowImplicitLocal, docs...)
|
||||
}
|
||||
return h.caps.pipe.IndexChunks(ctx, core.ID(h.owner), docs)
|
||||
return h.caps.pipe.IndexChunksAccountChecked(ctx, core.ID(h.owner), docs, h.caps.allowImplicitLocal)
|
||||
}
|
||||
|
||||
// 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 err := h.requireEffective(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(docs) == 0 {
|
||||
return nil
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
scoped := h.caps.projects != nil && h.caps.projects.active()
|
||||
if embed {
|
||||
return h.caps.pipe.PublishChunksAccountChecked(ctx, core.ID(h.owner), h.pluginID, scoped, docs, h.caps.allowImplicitLocal)
|
||||
}
|
||||
return h.caps.st.ReplaceKnowledgeAccountChecked(ctx, h.owner, h.pluginID, scoped, h.caps.allowImplicitLocal, docs...)
|
||||
}
|
||||
|
||||
// KnowledgeDelete 撤销本插件来源(EXT-9):前缀强制 plugin:<自身ID>——manual
|
||||
// 或他人来源拒绝(fail-closed);删行+向量,幂等。
|
||||
func (h inprocHost) KnowledgeDelete(ctx context.Context, sourcePrefix string) error {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return err
|
||||
}
|
||||
if sourcePrefix != "plugin:"+h.pluginID {
|
||||
return fmt.Errorf("ext-9: plugins may only delete their own source prefix, want %q", "plugin:"+h.pluginID)
|
||||
}
|
||||
_, err := h.caps.st.DeleteKnowledgeBySourceAccountChecked(ctx, h.owner, sourcePrefix, h.caps.allowImplicitLocal)
|
||||
return err
|
||||
}
|
||||
|
||||
// TaskCreate/TaskUpdate 后台任务台账:基座只记进度/状态,执行体由下游自跑(H5)。
|
||||
func (h inprocHost) TaskCreate(kind, title, conversationID string) (string, error) {
|
||||
t, err := h.caps.st.CreateBackgroundTask(h.owner, kind, title, conversationID)
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
t, err := h.caps.st.CreateBackgroundTaskAccountChecked(h.owner, kind, title, conversationID, h.caps.allowImplicitLocal)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -103,14 +435,81 @@ func (h inprocHost) TaskCreate(kind, title, conversationID string) (string, erro
|
||||
}
|
||||
|
||||
func (h inprocHost) TaskUpdate(taskID, status string, progress int, errText string) error {
|
||||
return h.caps.st.UpdateBackgroundTask(h.owner, taskID, status, progress, errText)
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.caps.st.UpdateBackgroundTaskAccountChecked(h.owner, taskID, status, progress, errText, h.caps.allowImplicitLocal)
|
||||
}
|
||||
|
||||
// ArtifactGet/ArtifactContent 工件只读(R1-3 ingest 读原件;写入属完整集)。
|
||||
func (h inprocHost) ArtifactGet(artifactID string) (core.Artifact, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return core.Artifact{}, err
|
||||
}
|
||||
return h.caps.st.GetArtifact(h.owner, artifactID)
|
||||
}
|
||||
|
||||
func (h inprocHost) ArtifactContent(artifactID string) ([]byte, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h.caps.st.ArtifactContent(h.owner, artifactID)
|
||||
}
|
||||
|
||||
// ParseDocument 内置文档解析(EXT-5):委托 hostcaps.parse;纯计算不经存储,
|
||||
// owner 作用域由调用方经 ArtifactContent 取字节时保证。
|
||||
func (h inprocHost) ParseDocument(ctx context.Context, filename, mime string, content []byte) (core.ParsedDocument, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return core.ParsedDocument{}, err
|
||||
}
|
||||
return h.caps.parse(ctx, filename, mime, content)
|
||||
}
|
||||
|
||||
// ---- EXT-17 Surface 能力(可选):nil 槽=能力缺失,明确拒绝不降级 ----
|
||||
|
||||
func (h inprocHost) surface() (*surfacesvc.Service, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.caps.surfaces == nil {
|
||||
return nil, surfacesvc.ErrUnavailable
|
||||
}
|
||||
return h.caps.surfaces, nil
|
||||
}
|
||||
|
||||
func (h inprocHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRequest) (core.SurfaceSnapshot, error) {
|
||||
svc, err := h.surface()
|
||||
if err != nil {
|
||||
return core.SurfaceSnapshot{}, err
|
||||
}
|
||||
return svc.SubmitAccountChecked(ctx, h.owner, h.pluginID, surfacesvc.SubmitRequest{
|
||||
SurfaceID: req.SurfaceID, ConversationID: req.ConversationID, SourceRunID: req.SourceRunID,
|
||||
BaseRevision: req.BaseRevision, ObservedEpoch: req.ObservedEpoch,
|
||||
ContextVersion: req.ContextVersion, SemanticText: req.SemanticText, Spec: req.Spec,
|
||||
}, h.caps.allowImplicitLocal)
|
||||
}
|
||||
|
||||
func (h inprocHost) SurfaceGet(ctx context.Context, surfaceID core.ID) (core.SurfaceSnapshot, error) {
|
||||
svc, err := h.surface()
|
||||
if err != nil {
|
||||
return core.SurfaceSnapshot{}, err
|
||||
}
|
||||
return svc.Get(ctx, h.owner, surfaceID)
|
||||
}
|
||||
|
||||
func (h inprocHost) SurfaceList(ctx context.Context, conversationID core.ID) ([]core.SurfaceMeta, error) {
|
||||
svc, err := h.surface()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return svc.List(ctx, h.owner, conversationID)
|
||||
}
|
||||
|
||||
func (h inprocHost) SurfaceActivation(ctx context.Context) (bool, int64, error) {
|
||||
svc, err := h.surface()
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
row, err := svc.Activation(ctx, h.owner, h.pluginID)
|
||||
return row.Enabled, row.Epoch, err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
@@ -76,6 +78,9 @@ func TestInprocHostOwnerScope(t *testing.T) {
|
||||
bus, st := newInprocTestApp(t, nil) // embedder nil:embed=true 仍只落全文(IndexChunks 降级路径)
|
||||
|
||||
for _, owner := range []string{"alice", "bob"} {
|
||||
if err := st.EnsureUser(owner, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := bus.Dispatch(context.Background(), cmdbus.Request{
|
||||
ID: "tst.record", Params: map[string]any{"ref": owner + "-ref"}, Owner: owner, Actor: "ui", Entrance: "chat",
|
||||
})
|
||||
@@ -155,6 +160,9 @@ func TestInprocHostKnowledgeWriteEmbedHash(t *testing.T) {
|
||||
},
|
||||
})
|
||||
bus, st := newInprocTestApp(t, func() string { return "hash" })
|
||||
if err := st.EnsureUser("alice", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp := bus.Dispatch(context.Background(), cmdbus.Request{
|
||||
ID: "tst.embed", Params: map[string]any{"text": "Hash 向量内容"}, Owner: "alice", Entrance: "api",
|
||||
@@ -193,3 +201,112 @@ func TestInprocHostKnowledgeWriteEmbedHash(t *testing.T) {
|
||||
t.Fatalf("vector owner leak: %+v", bhits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInprocHostParseDocumentOwnerScope:EXT-5 解析方法的 owner 作用域——
|
||||
// alice 经句柄读自己的工件并解析;bob 用同一工件 ID 在 ArtifactContent 处
|
||||
// 即被拒(解析是纯计算,作用域边界在取原件一环)。
|
||||
func TestInprocHostParseDocumentOwnerScope(t *testing.T) {
|
||||
extension.ResetCompiled()
|
||||
t.Cleanup(extension.ResetCompiled)
|
||||
extension.RegisterCompiled(extension.InProc{
|
||||
ID: "tst",
|
||||
Register: func(reg extension.Registry) error {
|
||||
return reg.AddCommandHandler(json.RawMessage(`{"id":"tst.parse","title":"解析"}`),
|
||||
func(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
|
||||
id, _ := req.Params["artifactId"].(string)
|
||||
content, err := h.ArtifactContent(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc, err := h.ParseDocument(ctx, "报价.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"parser": doc.Meta.Parser,
|
||||
"warnings": len(doc.Warnings),
|
||||
"blocks": len(doc.Blocks),
|
||||
}, nil
|
||||
})
|
||||
},
|
||||
})
|
||||
bus, st := newInprocTestApp(t, nil)
|
||||
|
||||
art := core.Artifact{ProjectID: "p1", Owner: "alice", Name: "报价.xlsx", Kind: "document"}
|
||||
if err := st.CreateArtifact(&art, testXLSXBytes(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
artID := art.ID
|
||||
|
||||
resp := bus.Dispatch(context.Background(), cmdbus.Request{
|
||||
ID: "tst.parse", Params: map[string]any{"artifactId": artID}, Owner: "alice", Entrance: "chat",
|
||||
})
|
||||
if resp.Receipt.State != agentcommand.StateCommitted {
|
||||
t.Fatalf("alice dispatch: %+v %s", resp.Receipt, resp.Receipt.Reason)
|
||||
}
|
||||
m, _ := resp.Payload.(map[string]any)
|
||||
if m["parser"] != "builtin:xlsx" || m["warnings"] != 0 || m["blocks"] == 0 {
|
||||
t.Fatalf("alice payload: %+v", m)
|
||||
}
|
||||
|
||||
// bob 同一工件 ID:取原件即被拒(跨 owner 不可见)。
|
||||
resp = bus.Dispatch(context.Background(), cmdbus.Request{
|
||||
ID: "tst.parse", Params: map[string]any{"artifactId": artID}, Owner: "bob", Entrance: "chat",
|
||||
})
|
||||
if resp.Receipt.State == agentcommand.StateCommitted {
|
||||
t.Fatalf("bob must not read alice's artifact: %+v", resp.Payload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInprocHostParseDocumentLimits:仅有的两条 error 路径——ctx 取消与输入
|
||||
// 超过 100MiB 上限;不可解析格式仍是带 Warnings 的显式失败。
|
||||
func TestInprocHostParseDocumentLimits(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "t.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "alice"}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := h.ParseDocument(ctx, "a.xlsx", "", []byte("x")); err == nil {
|
||||
t.Fatal("canceled ctx must error")
|
||||
}
|
||||
if _, err := h.ParseDocument(context.Background(), "a.xlsx", "", make([]byte, maxParseInput+1)); err == nil {
|
||||
t.Fatal("oversized input must error")
|
||||
}
|
||||
doc, err := h.ParseDocument(context.Background(), "旧.xls", "", []byte{0xD0, 0xCF})
|
||||
if err != nil {
|
||||
t.Fatalf("legacy format is a visible failure, not an error: %v", err)
|
||||
}
|
||||
if doc.Meta.Parser != "builtin:legacy-office" || len(doc.Warnings) != 1 {
|
||||
t.Fatalf("legacy doc: %+v %v", doc.Meta, doc.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// testXLSXBytes 构造最小可用 xlsx(单表两行),供句柄测试解析。
|
||||
func testXLSXBytes(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
files := map[string]string{
|
||||
"xl/workbook.xml": `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="报价" sheetId="1" r:id="rId1"/></sheets></workbook>`,
|
||||
"xl/_rels/workbook.xml.rels": `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>`,
|
||||
"xl/worksheets/sheet1.xml": `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>
|
||||
<row r="1"><c r="A1" t="inlineStr"><is><t>物料</t></is></c><c r="B1" t="inlineStr"><is><t>单价</t></is></c></row>
|
||||
<row r="2"><c r="A2" t="inlineStr"><is><t>支架</t></is></c><c r="B2"><v>12.5</v></c></row>
|
||||
</sheetData></worksheet>`,
|
||||
}
|
||||
for _, name := range []string{"xl/workbook.xml", "xl/_rels/workbook.xml.rels", "xl/worksheets/sheet1.xml"} {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Write([]byte(files[name]))
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/settings"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 慢通道开关默认关(行为与 I1 一致的机器证明):
|
||||
// 两条 runtime.intentSemantic / runtime.intentGenerator 必须注册且默认 false。
|
||||
func TestIntentSlowLaneFlagsDefaultOff(t *testing.T) {
|
||||
seen := map[string]core.SettingDesc{}
|
||||
for _, d := range settings.Schema() {
|
||||
seen[d.Key] = d
|
||||
}
|
||||
for _, key := range []string{"runtime.intentSemantic", "runtime.intentGenerator"} {
|
||||
d, ok := seen[key]
|
||||
if !ok {
|
||||
t.Fatalf("设置未注册:%s", key)
|
||||
}
|
||||
if d.Type != core.SettingBool {
|
||||
t.Fatalf("%s 类型应为 bool,得到 %v", key, d.Type)
|
||||
}
|
||||
if def, isBool := d.Default.(bool); !isBool || def {
|
||||
t.Fatalf("%s 必须默认 false,得到 %v", key, d.Default)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 遥测开关默认安全(N3 WP4):otlpEndpoint 默认空(不导出,保持现状),
|
||||
// serviceName 有非空默认。两处都注册进 Schema。
|
||||
func TestTelemetryFlagsDefaults(t *testing.T) {
|
||||
seen := map[string]core.SettingDesc{}
|
||||
for _, d := range settings.Schema() {
|
||||
seen[d.Key] = d
|
||||
}
|
||||
if d, ok := seen["telemetry.otlpEndpoint"]; !ok || d.Default != "" {
|
||||
t.Fatalf("otlpEndpoint 必须注册且默认空:%+v", d)
|
||||
}
|
||||
if d, ok := seen["telemetry.serviceName"]; !ok || d.Default != "agent-system-backend" {
|
||||
t.Fatalf("serviceName 必须注册且默认 agent-system-backend:%+v", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
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()
|
||||
for _, owner := range []string{"alice", "bob"} {
|
||||
if err := st.EnsureUser(owner, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
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()
|
||||
if err := st.EnsureUser("alice", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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()
|
||||
if err := st.EnsureUser("alice", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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.EnsureUser("alice", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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,98 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func TestLocalOwnerAccountCreationAndReissuePreserveIdentity(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
seed, err := store.OpenSQLite(filepath.Join(dir, "agent-system.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := seed.EnsureSystemIdentity("test-local-owner", "sqlite"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed.Close()
|
||||
bootstrap, err := BootstrapAdmin("", dir, "root", false)
|
||||
if err != nil || bootstrap == "" {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
first, _, err := CreateUserAccount("", dir, "alice", "", "", false, true, false)
|
||||
if err != nil || first == "" {
|
||||
t.Fatalf("local pending create: %v", err)
|
||||
}
|
||||
st, err := store.OpenExistingSQLite(filepath.Join(dir, "agent-system.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account, err := st.GetAccountByOwner("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Close()
|
||||
second, owner, err := InitializeAccount("", dir, account.ID, account.StateVersion, account.InitializationGeneration)
|
||||
if err != nil || owner != "alice" || second == first {
|
||||
t.Fatalf("owner reissue: owner=%s err=%v", owner, err)
|
||||
}
|
||||
st, err = store.OpenExistingSQLite(filepath.Join(dir, "agent-system.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
current, err := st.GetAccount(account.ID)
|
||||
if err != nil || current.Owner != account.Owner || current.ID != account.ID || current.InitializationGeneration != account.InitializationGeneration+1 {
|
||||
t.Fatalf("reissue changed account identity: %+v %v", current, err)
|
||||
}
|
||||
if _, _, err := st.RedeemLoginKey(first); err == nil {
|
||||
t.Fatal("old activation grant survived reissue")
|
||||
}
|
||||
if _, _, err := st.RedeemLoginKey(second); err != nil {
|
||||
t.Fatalf("new activation grant failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeyCLIRequiresExistingInitializedAccount(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := store.OpenSQLite(filepath.Join(dir, "agent-system.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.EnsureSystemIdentity("test-api-key-admission", "sqlite"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Close()
|
||||
if _, err := CreateAPIKey("", dir, "alice", "integration"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("missing account was implicitly created: %v", err)
|
||||
}
|
||||
grant, err := BootstrapAdmin("", dir, "alice", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CreateAPIKey("", dir, "alice", "integration"); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("pending account received a full API key: %v", err)
|
||||
}
|
||||
st, err = store.OpenExistingSQLite(filepath.Join(dir, "agent-system.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
token, _, err := st.RedeemLoginKey(grant)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := store.HashPassword("trusted-owner-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.CompleteSetPasswordChecked(token, hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key, err := CreateAPIKey("", dir, "alice", "integration"); err != nil || key == "" {
|
||||
t.Fatalf("initialized account could not receive its API key: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
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
|
||||
}
|
||||
|
||||
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 requestBoundHost{inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder}}
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
return h.caps.st.GetProject(h.owner, id)
|
||||
}
|
||||
func (h inprocHost) ProjectMetadata(id string) (core.Project, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
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) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
if h.actor != "" && h.actor != h.owner {
|
||||
return core.Project{}, store.ErrAccountForbidden
|
||||
}
|
||||
// A creator-scoped Host is the approved I1 projection capability. RDM
|
||||
// authorizes a delegated operator before invoking it; cross-database
|
||||
// commit fencing remains outside U3's guarantee.
|
||||
return h.caps.st.EnsureProjectAccountChecked(h.owner, h.owner, id, name, h.caps.allowImplicitLocal)
|
||||
}
|
||||
func (h inprocHost) RenameOwnedProject(id, name string) (core.Project, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
if h.actor != "" && h.actor != h.owner {
|
||||
return core.Project{}, store.ErrAccountForbidden
|
||||
}
|
||||
return h.caps.st.RenameOwnedProjectAccountChecked(h.owner, h.owner, id, name, h.caps.allowImplicitLocal)
|
||||
}
|
||||
func (h inprocHost) ProjectNameAvailable(name, exceptID string) (bool, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return h.caps.st.ProjectNameAvailable(name, exceptID)
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
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"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
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()
|
||||
for _, owner := range []string{"alice", "bob"} {
|
||||
if err := st.EnsureUser(owner, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
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)
|
||||
if err := st.EnsureUser("bob", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestBoundDelegatedProjectionFencesRealOperator(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/delegated-project.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
for _, account := range []struct {
|
||||
owner string
|
||||
admin bool
|
||||
}{{"admin", true}, {"alice", false}, {"bob", false}} {
|
||||
if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
project, err := st.CreateProject("alice", "original")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guard := accountEffectiveAuthorization(st, true)
|
||||
check := func(owner string) error {
|
||||
valid, err := guard(context.Background(), owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !valid {
|
||||
return store.ErrAccountForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
slot := &projectLifecycleSlot{}
|
||||
caps := hostcaps{st: st, projects: slot, accountGuard: check}
|
||||
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := requestBoundHost{inprocHost{caps: caps, pluginID: "rdm", owner: "bob", actor: "bob"}}
|
||||
delegator, ok := any(request).(interface {
|
||||
ProjectProjectionForCreator(creator, projectID string) (extension.ProjectHost, error)
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("request-bound delegated projection capability missing")
|
||||
}
|
||||
projection, err := delegator.ProjectProjectionForCreator("alice", string(project.ID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if projection.Owner() != "alice" {
|
||||
t.Fatalf("trusted projection owner=%s", projection.Owner())
|
||||
}
|
||||
if _, ok := projection.(extension.Host); ok {
|
||||
t.Fatal("single-project projection exposed broad creator Host capabilities")
|
||||
}
|
||||
if _, err := projection.RenameOwnedProject(string(project.ID), "delegated"); err != nil {
|
||||
t.Fatalf("active delegated rename: %v", err)
|
||||
}
|
||||
if _, err := projection.RenameOwnedProject("another-project", "hijack"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("projection escaped bound project: %v", err)
|
||||
}
|
||||
if _, err := projection.EnsureProject("another-project", "hijack"); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("projection created another project: %v", err)
|
||||
}
|
||||
bob, err := st.GetAccountByOwner("bob")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := projection.RenameOwnedProject(string(project.ID), "late"); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled real operator renamed creator project: %v", err)
|
||||
}
|
||||
got, err := st.ProjectMetadata(string(project.ID))
|
||||
if err != nil || got.Name != "delegated" {
|
||||
t.Fatalf("late rename changed project: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegatedProjectionRejectsDisableBetweenEarlyCheckAndWrite(t *testing.T) {
|
||||
var st, other *store.Store
|
||||
var err error
|
||||
if baseDSN := os.Getenv("AGENT_PG_TEST_DSN"); baseDSN != "" {
|
||||
st = openDirectoryTestStore(t)
|
||||
if err := st.EnsureSystemIdentity("u3-delegated-projection", "pg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var schema string
|
||||
if err := st.DB().QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
separator := "?"
|
||||
if strings.Contains(baseDSN, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
other, err = store.OpenExistingPostgres(baseDSN+separator+"search_path="+schema+",public", filepath.Join(t.TempDir(), "other-artifacts"))
|
||||
} else {
|
||||
db := filepath.Join(t.TempDir(), "shared.db")
|
||||
st, err = store.OpenSQLite(db)
|
||||
if err == nil {
|
||||
other, err = store.OpenExistingSQLite(db)
|
||||
}
|
||||
if st != nil {
|
||||
defer st.Close()
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer other.Close()
|
||||
for _, account := range []struct {
|
||||
owner string
|
||||
admin bool
|
||||
}{{"admin", true}, {"alice", false}, {"bob", false}} {
|
||||
if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
project, err := st.CreateProject("alice", "before")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entered, release := make(chan struct{}), make(chan struct{})
|
||||
var bobChecks atomic.Int32
|
||||
check := func(owner string) error {
|
||||
state, err := st.GetUserAuthState(owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !state.Enabled {
|
||||
return store.ErrAccountForbidden
|
||||
}
|
||||
if owner == "bob" && bobChecks.Add(1) == 2 {
|
||||
close(entered)
|
||||
<-release
|
||||
}
|
||||
return nil
|
||||
}
|
||||
slot := &projectLifecycleSlot{}
|
||||
caps := hostcaps{st: st, projects: slot, accountGuard: check}
|
||||
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host := requestBoundHost{inprocHost{caps: caps, pluginID: "rdm", owner: "bob", actor: "bob"}}
|
||||
projection, err := host.ProjectProjectionForCreator("alice", string(project.ID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
written := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := projection.RenameOwnedProject(string(project.ID), "late")
|
||||
written <- err
|
||||
}()
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("rename did not reach paused early account check")
|
||||
}
|
||||
bob, err := st.GetAccountByOwner("bob")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := other.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case err := <-written:
|
||||
if !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("late delegated rename committed: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("late rename did not return")
|
||||
}
|
||||
got, err := st.ProjectMetadata(string(project.ID))
|
||||
if err != nil || got.Name != "before" {
|
||||
t.Fatalf("project changed after disable: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/rag"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
)
|
||||
|
||||
// EXT-11 红测:注入前 NewScopedHost fail-closed;注入后重建句柄与分发期同构
|
||||
// (owner 作用域、PluginID、KV 隔离、知识删除前缀强制)。
|
||||
func TestScopedHostFactory(t *testing.T) {
|
||||
// 注入前:fail-closed。
|
||||
if _, err := extension.NewScopedHost("alice", "rdm"); err == nil {
|
||||
t.Fatal("before wiring, NewScopedHost must fail closed(工厂未注入)")
|
||||
}
|
||||
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "t.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
if err := st.EnsureUser("alice", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set := settings.NewService(st)
|
||||
pipe := rag.NewPipeline(st, set, nil, nil)
|
||||
caps := hostcaps{st: st, pipe: pipe, set: set}
|
||||
|
||||
// 组合根同款注入(app.Run 内以 wireScopedHostFactory 接线;此处直接调内部函数)。
|
||||
wireScopedHostFactory(caps)
|
||||
t.Cleanup(func() { extension.SetScopedHostFactory(nil) })
|
||||
|
||||
h, err := extension.NewScopedHost("alice", "rdm")
|
||||
if err != nil {
|
||||
t.Fatalf("NewScopedHost: %v", err)
|
||||
}
|
||||
if h.Owner() != "alice" || h.PluginID() != "rdm" {
|
||||
t.Fatalf("Owner/PluginID = %s/%s, want alice/rdm", h.Owner(), h.PluginID())
|
||||
}
|
||||
if _, ok := h.(extension.DelegatedProjectProjectionHost); ok {
|
||||
t.Fatal("globally reconstructed Host exposed request-bound projection authority")
|
||||
}
|
||||
// KV 往返落 owner 作用域。
|
||||
if err := h.KVPut("k1", "v1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, ok, err := h.KVGet("k1"); err != nil || !ok || v != "v1" {
|
||||
t.Fatalf("KVGet = %q ok=%v err=%v", v, ok, err)
|
||||
}
|
||||
// 同构性:知识删除前缀强制(manual 不可及)。
|
||||
if err := h.KnowledgeDelete(context.Background(), "manual"); err == nil {
|
||||
t.Fatal("scoped host must keep the plugin:<id> prefix enforcement")
|
||||
}
|
||||
// 空 owner/pluginID:拒绝。
|
||||
if _, err := extension.NewScopedHost("", "rdm"); err == nil {
|
||||
t.Fatal("empty owner must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 协作存储方案 U2(KTD1/R2):服务入口的部署档门禁。
|
||||
// team 档缺 DSN / 未开认证必须拒绝启动——不创建 SQLite、不进入装配。
|
||||
func TestTierRefusalTeam(t *testing.T) {
|
||||
msgs := TierRefusal(Options{Tier: "team", Addr: "127.0.0.1:8788"})
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("team without dsn/auth: %v", msgs)
|
||||
}
|
||||
joined := strings.Join(msgs, ";")
|
||||
if !strings.Contains(joined, "PostgreSQL DSN") || !strings.Contains(joined, "认证") {
|
||||
t.Fatalf("refusal wording: %v", msgs)
|
||||
}
|
||||
msgs = TierRefusal(Options{Tier: "team", Addr: "127.0.0.1:8788", StorageDSN: "postgres://u:p@h/db", AuthMode: "on"})
|
||||
if len(msgs) != 0 {
|
||||
t.Fatalf("team with dsn+auth should pass: %v", msgs)
|
||||
}
|
||||
msgs = TierRefusal(Options{Tier: "local", Addr: "127.0.0.1:8788", StorageDSN: "postgres://u:p@h/db"})
|
||||
if len(msgs) != 1 || !strings.Contains(msgs[0], "local 档不使用") {
|
||||
t.Fatalf("local with dsn: %v", msgs)
|
||||
}
|
||||
msgs = TierRefusal(Options{Addr: "127.0.0.1:8788"})
|
||||
if len(msgs) != 0 {
|
||||
t.Fatalf("auto default: %v", msgs)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package blobstore
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// U5/KTD9:S3 兼容对象存储实现——针对嵌入式测试服务器验证
|
||||
// 签名请求、Put/Get/Delete 语义与预签名 URL 生成。
|
||||
// (真实 S3 兼容服务的端到端验收随 U7 压测环境执行;此处锁定协议正确性。)
|
||||
|
||||
func newTestS3(t *testing.T, base string) *S3 {
|
||||
t.Helper()
|
||||
s, err := NewS3(base, "us-east-1", "AKIDEXAMPLE", "secret", "art/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestS3PutGetDeleteRoundTrip(t *testing.T) {
|
||||
var gotPath, gotMethod, gotAuth string
|
||||
var gotBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath, gotMethod, gotAuth = r.URL.Path, r.Method, r.Header.Get("Authorization")
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := r.Body.Read(buf)
|
||||
gotBody = buf[:n]
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
w.WriteHeader(200)
|
||||
case http.MethodGet:
|
||||
if strings.Contains(gotPath, "obj404") {
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
w.Write([]byte("payload"))
|
||||
case http.MethodDelete:
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
s := newTestS3(t, srv.URL+"/bucket")
|
||||
|
||||
ref, err := s.Put("doc1", []byte("payload"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ref != "art/doc1" {
|
||||
t.Fatalf("ref=%s(对象键应带前缀)", ref)
|
||||
}
|
||||
if gotPath != "/bucket/art/doc1" || gotMethod != http.MethodPut || string(gotBody) != "payload" {
|
||||
t.Fatalf("put 请求:%s %s body=%q", gotMethod, gotPath, gotBody)
|
||||
}
|
||||
if len(gotAuth) == 0 || gotAuth[:17] != "AWS4-HMAC-SHA256 " {
|
||||
t.Fatalf("Authorization 头:%q", gotAuth)
|
||||
}
|
||||
got, err := s.Get(ref)
|
||||
if err != nil || string(got) != "payload" {
|
||||
t.Fatalf("get: %q err=%v", got, err)
|
||||
}
|
||||
if err := s.Delete(ref); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
// 404 → 哨兵错误(服务端对 art/obj404 的 GET 返回 404)。
|
||||
if _, err := s.Get("art/obj404"); err == nil || err.Error() != ErrObjectNotFound.Error() {
|
||||
t.Fatalf("404 应 ErrObjectNotFound:%v", err)
|
||||
}
|
||||
// Delete 幂等(404 视为成功)。
|
||||
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(404)
|
||||
}))
|
||||
defer srv2.Close()
|
||||
s2 := newTestS3(t, srv2.URL+"/b")
|
||||
if err := s2.Delete("art/none"); err != nil {
|
||||
t.Fatalf("delete 404 应幂等:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3PresignGet(t *testing.T) {
|
||||
s := newTestS3(t, "https://s3.example.com/bucket")
|
||||
u, err := s.PresignGet("art/doc1", 15*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"X-Amz-Algorithm=AWS4-HMAC-SHA256", "X-Amz-Signature=", "X-Amz-Expires=900", "/bucket/art/doc1"} {
|
||||
if !contains(u, want) {
|
||||
t.Fatalf("预签名 URL 缺 %s:%s", want, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (func() bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})()
|
||||
}
|
||||
|
||||
func TestNewS3FromEnvFallback(t *testing.T) {
|
||||
os.Unsetenv("BLOBSTORE_S3_ENDPOINT")
|
||||
s, err := NewS3FromEnv()
|
||||
if s != nil || err != nil {
|
||||
t.Fatalf("未配置端点应回落 (nil,nil):s=%v err=%v", s, err)
|
||||
}
|
||||
t.Setenv("BLOBSTORE_S3_ENDPOINT", "http://x/b")
|
||||
t.Setenv("BLOBSTORE_S3_ACCESS_KEY", "a")
|
||||
t.Setenv("BLOBSTORE_S3_SECRET_KEY", "b")
|
||||
s, err = NewS3FromEnv()
|
||||
if err != nil || s == nil {
|
||||
t.Fatalf("已配置应构造:s=%v err=%v", s, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package blobstore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// S3 是 S3 兼容对象存储实现(协作存储 U5/KTD9:多实例共享工件)。
|
||||
// 认证:AWS Signature V4(静态 AccessKey/SecretKey;凭据只经环境或受保护
|
||||
// 配置注入,引用形态为对象键,不含凭据)。
|
||||
//
|
||||
// 语义(KTD9):
|
||||
// - Put:先完整写对象再由上层提交引用(失败孤儿可清理);
|
||||
// - Get:按对象键读取;
|
||||
// - Delete:立即删除(标记/延迟 GC 由桶生命周期策略承担)。
|
||||
type S3 struct {
|
||||
Base string // 端点(含 bucket 路径,如 https://s3.example.com/bucket)
|
||||
Region string
|
||||
Access string
|
||||
Secret string
|
||||
Prefix string // 对象键前缀(租户/应用隔离)
|
||||
client *http.Client
|
||||
nowFunc func() time.Time
|
||||
}
|
||||
|
||||
// NewS3 从显式参数构造;nil client 用默认。
|
||||
func NewS3(base, region, access, secret, prefix string) (*S3, error) {
|
||||
if base == "" || access == "" || secret == "" {
|
||||
return nil, errors.New("blobstore: s3 requires base/access/secret")
|
||||
}
|
||||
if !strings.HasSuffix(base, "/") {
|
||||
base += "/"
|
||||
}
|
||||
return &S3{Base: base, Region: region, Access: access, Secret: secret, Prefix: prefix,
|
||||
client: &http.Client{Timeout: 30 * time.Second}, nowFunc: time.Now}, nil
|
||||
}
|
||||
|
||||
// NewS3FromEnv 按环境构造:BLOBSTORE_S3_ENDPOINT/REGION/ACCESS_KEY/SECRET_KEY/PREFIX。
|
||||
// 未配置端点返回 (nil, nil)(调用方回落本地档)。
|
||||
func NewS3FromEnv() (*S3, error) {
|
||||
if os.Getenv("BLOBSTORE_S3_ENDPOINT") == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return NewS3(os.Getenv("BLOBSTORE_S3_ENDPOINT"),
|
||||
envOr("BLOBSTORE_S3_REGION", "us-east-1"),
|
||||
os.Getenv("BLOBSTORE_S3_ACCESS_KEY"),
|
||||
os.Getenv("BLOBSTORE_S3_SECRET_KEY"),
|
||||
os.Getenv("BLOBSTORE_S3_PREFIX"))
|
||||
}
|
||||
|
||||
func envOr(k, d string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (s *S3) key(id string) (string, error) {
|
||||
if !validID(id) {
|
||||
return "", fmt.Errorf("blobstore: invalid object id %q", id)
|
||||
}
|
||||
return s.Prefix + id, nil
|
||||
}
|
||||
|
||||
// Put 上传对象;ref=对象键。
|
||||
func (s *S3) Put(id string, content []byte) (string, error) {
|
||||
key, err := s.key(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := s.do(http.MethodPut, key, content, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
return "", fmt.Errorf("blobstore: s3 put %s: %s %s", key, resp.Status, body)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Get 下载对象。
|
||||
func (s *S3) Get(ref string) ([]byte, error) {
|
||||
resp, err := s.do(http.MethodGet, ref, nil, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, ErrObjectNotFound
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return nil, fmt.Errorf("blobstore: s3 get %s: %s", ref, resp.Status)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// ErrObjectNotFound 对象不存在(与本地档 ErrNotExist 对齐的哨兵)。
|
||||
var ErrObjectNotFound = errors.New("blobstore: object not found")
|
||||
|
||||
// Delete 删除对象(幂等:404 视为成功)。
|
||||
func (s *S3) Delete(ref string) error {
|
||||
resp, err := s.do(http.MethodDelete, ref, nil, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 == 2 || resp.StatusCode == http.StatusNotFound {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("blobstore: s3 delete %s: %s", ref, resp.Status)
|
||||
}
|
||||
|
||||
// do 签名并执行请求(AWS SigV4,service=s3)。
|
||||
func (s *S3) do(method, key string, body []byte, contentType string) (*http.Response, error) {
|
||||
u, err := url.Parse(s.Base + key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := s.nowFunc().UTC()
|
||||
amzDate := now.Format("20060102T150405Z")
|
||||
dateStamp := now.Format("20060102")
|
||||
payloadHash := sha256.Sum256(body)
|
||||
payloadHex := hex.EncodeToString(payloadHash[:])
|
||||
|
||||
req, err := http.NewRequest(method, u.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("x-amz-date", amzDate)
|
||||
req.Header.Set("x-amz-content-sha256", payloadHex)
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
// 规范请求与签名(SigV4 单 chunk)。
|
||||
canonicalURI := "/" + strings.TrimPrefix(u.Path, "/")
|
||||
canonicalQuery := u.RawQuery
|
||||
var ch bytes.Buffer
|
||||
ch.WriteString(method + "\n" + canonicalURI + "\n" + canonicalQuery + "\n")
|
||||
ch.WriteString("host:" + u.Host + "\n")
|
||||
ch.WriteString("x-amz-content-sha256:" + payloadHex + "\n")
|
||||
ch.WriteString("x-amz-date:" + amzDate + "\n")
|
||||
ch.WriteString("\nhost;x-amz-content-sha256;x-amz-date\n" + payloadHex)
|
||||
cr := ch.Bytes()
|
||||
|
||||
scope := dateStamp + "/" + s.Region + "/s3/aws4_request"
|
||||
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + hex.EncodeToString(sha256sum(cr))
|
||||
kDate := hmacSHA256([]byte("AWS4"+s.Secret), dateStamp)
|
||||
kRegion := hmacSHA256(kDate, s.Region)
|
||||
kService := hmacSHA256(kRegion, "s3")
|
||||
kSigning := hmacSHA256(kService, "aws4_request")
|
||||
sig := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
|
||||
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+s.Access+"/"+scope+
|
||||
", SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="+sig)
|
||||
return s.client.Do(req)
|
||||
}
|
||||
|
||||
func sha256sum(b []byte) []byte {
|
||||
h := sha256.Sum256(b)
|
||||
return h[:]
|
||||
}
|
||||
|
||||
func hmacSHA256(key []byte, data string) []byte {
|
||||
m := hmac.New(sha256.New, key)
|
||||
m.Write([]byte(data))
|
||||
return m.Sum(nil)
|
||||
}
|
||||
|
||||
// PresignGet 生成限时 GET 预签名 URL(下载入口;有效期由调用方指定)。
|
||||
// 用于把大对象下载直接指向对象存储,应用实例不做数据中继。
|
||||
func (s *S3) PresignGet(ref string, ttl time.Duration) (string, error) {
|
||||
u, err := url.Parse(s.Base + ref)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
now := s.nowFunc().UTC()
|
||||
amzDate := now.Format("20060102T150405Z")
|
||||
dateStamp := now.Format("20060102")
|
||||
expiry := int64(ttl / time.Second)
|
||||
payloadHex := hex.EncodeToString(sha256sum(nil))
|
||||
q := u.Query()
|
||||
q.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256")
|
||||
q.Set("X-Amz-Credential", s.Access+"/"+dateStamp+"/"+s.Region+"/s3/aws4_request")
|
||||
q.Set("X-Amz-Date", amzDate)
|
||||
q.Set("X-Amz-Expires", fmt.Sprint(expiry))
|
||||
q.Set("X-Amz-SignedHeaders", "host")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
canonicalQuery := canonicalQueryEncode(u)
|
||||
var ch bytes.Buffer
|
||||
ch.WriteString("GET\n/" + strings.TrimPrefix(u.Path, "/") + "\n" + canonicalQuery + "\n")
|
||||
ch.WriteString("host:" + u.Host + "\n")
|
||||
ch.WriteString("x-amz-content-sha256:" + payloadHex + "\n")
|
||||
ch.WriteString("x-amz-date:" + amzDate + "\n")
|
||||
ch.WriteString("\nhost;x-amz-content-sha256;x-amz-date\n" + payloadHex)
|
||||
scope := dateStamp + "/" + s.Region + "/s3/aws4_request"
|
||||
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + hex.EncodeToString(sha256sum(ch.Bytes()))
|
||||
kDate := hmacSHA256([]byte("AWS4"+s.Secret), dateStamp)
|
||||
kRegion := hmacSHA256(kDate, s.Region)
|
||||
kService := hmacSHA256(kRegion, "s3")
|
||||
kSigning := hmacSHA256(kService, "aws4_request")
|
||||
sig := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
|
||||
return u.String() + "&X-Amz-Signature=" + sig, nil
|
||||
}
|
||||
|
||||
// canonicalQueryEncode 按 AWS 规则重排查询串(键排序 + URL 编码)。
|
||||
func canonicalQueryEncode(u *url.URL) string {
|
||||
q := u.Query()
|
||||
var keys []string
|
||||
for k := range q {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
// 排序(简单实现足够:键集固定且互不冲突)
|
||||
for i := 0; i < len(keys); i++ {
|
||||
for j := i + 1; j < len(keys); j++ {
|
||||
if keys[j] < keys[i] {
|
||||
keys[i], keys[j] = keys[j], keys[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
var sb strings.Builder
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
sb.WriteString("&")
|
||||
}
|
||||
sb.WriteString(url.QueryEscape(k) + "=" + url.QueryEscape(q[k][0]))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
var _ Store = (*S3)(nil)
|
||||
@@ -0,0 +1,219 @@
|
||||
package cardsvc
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func (s *Service) requireAccountCheckedEmitter() error {
|
||||
if s.emit != nil && s.accountCheckedEmit == nil {
|
||||
return ErrGuardedEmitterRequired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ResolveByRefAccountChecked(actor, ref, actionID, answer string, allowImplicitDefault bool) error {
|
||||
if err := s.requireAccountCheckedEmitter(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := s.st.CardIDByRef(actor, ref)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
card, version, err := s.st.GetCardVersion(actor, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if card.Status.Terminal() {
|
||||
return nil
|
||||
}
|
||||
card.Status = core.CardResolved
|
||||
if err := s.UpdateVersionedAccountChecked(actor, card, version, allowImplicitDefault); errors.Is(err, store.ErrConflict) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.emitCardAccountChecked(actor, card, core.EvCardResolved,
|
||||
core.CardResolvedPayload{CardID: card.ID, ActionID: actionID, Answer: answer})
|
||||
}
|
||||
|
||||
// AppendAccountChecked preserves stream-card throttling while fencing its
|
||||
// durable body update against account disable.
|
||||
func (s *Service) AppendAccountChecked(actor, id, text string, allowImplicitDefault bool) error {
|
||||
if err := s.requireAccountCheckedEmitter(); err != nil {
|
||||
return err
|
||||
}
|
||||
card, version, err := s.st.GetCardVersion(actor, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if text != "" {
|
||||
if n := len(card.Body); n > 0 && card.Body[n-1].Type == core.UIMarkdown {
|
||||
card.Body[n-1].Markdown += text
|
||||
} else {
|
||||
card.Body = append(card.Body, core.UINode{Type: core.UIMarkdown, Markdown: text})
|
||||
}
|
||||
}
|
||||
card.UpdatedAt = s.now().UTC()
|
||||
if err := s.st.SaveCardVersionedAccountChecked(actor, &card, "", version, allowImplicitDefault); err != nil {
|
||||
return err
|
||||
}
|
||||
if card.ConversationID == "" && card.ProjectID == "" {
|
||||
return nil
|
||||
}
|
||||
s.emitMu.Lock()
|
||||
last, seen := s.lastEmit[id]
|
||||
emit := !seen || s.now().Sub(last) >= cardAppendWindow
|
||||
if emit {
|
||||
s.lastEmit[id] = s.now()
|
||||
}
|
||||
s.emitMu.Unlock()
|
||||
if !emit {
|
||||
return nil
|
||||
}
|
||||
return s.emitCardAccountChecked(actor, card, core.EvCardUpdated, core.CardPayload{Card: card})
|
||||
}
|
||||
|
||||
func (s *Service) emitCardAccountChecked(actor string, card core.ActivityCard, typ core.StreamEventType, payload any) error {
|
||||
if s.accountCheckedEmit == nil || (card.ConversationID == "" && card.ProjectID == "") {
|
||||
return nil
|
||||
}
|
||||
if card.ConversationID != "" {
|
||||
if err := s.accountCheckedEmit(actor, card.ConversationID, card.SourceRunID, typ, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if !s.MirrorConvCards {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if card.ProjectID != "" {
|
||||
return s.accountCheckedEmit(actor, string(core.ProjectChannelKey(card.ProjectID)), card.SourceRunID, typ, payload)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateAccountChecked creates a card under the actor's final Store fence.
|
||||
func (s *Service) CreateAccountChecked(actor string, card core.ActivityCard, ref string, allowImplicitDefault bool) (core.ActivityCard, error) {
|
||||
if err := s.requireAccountCheckedEmitter(); err != nil {
|
||||
return card, err
|
||||
}
|
||||
if card.ID == "" {
|
||||
card.ID = store.NewID("card")
|
||||
}
|
||||
now := s.now().UTC()
|
||||
card.CreatedAt, card.UpdatedAt = now, now
|
||||
if card.Status == "" {
|
||||
card.Status = core.CardActive
|
||||
}
|
||||
if err := s.st.SaveCardAccountChecked(actor, &card, ref, allowImplicitDefault); err != nil {
|
||||
return card, err
|
||||
}
|
||||
return card, s.emitCardAccountChecked(actor, card, core.EvCardCreated, core.CardPayload{Card: card})
|
||||
}
|
||||
|
||||
func (s *Service) updateVersionedAccountChecked(actor string, card core.ActivityCard, expectedVersion int, allowImplicitDefault bool) (core.ActivityCard, error) {
|
||||
if err := s.requireAccountCheckedEmitter(); err != nil {
|
||||
return card, err
|
||||
}
|
||||
card.UpdatedAt = s.now().UTC()
|
||||
if err := s.st.SaveCardVersionedAccountChecked(actor, &card, "", expectedVersion, allowImplicitDefault); err != nil {
|
||||
return card, err
|
||||
}
|
||||
return card, s.emitCardAccountChecked(actor, card, core.EvCardUpdated, core.CardPayload{Card: card})
|
||||
}
|
||||
|
||||
func (s *Service) UpdateVersionedAccountChecked(actor string, card core.ActivityCard, expectedVersion int, allowImplicitDefault bool) error {
|
||||
_, err := s.updateVersionedAccountChecked(actor, card, expectedVersion, allowImplicitDefault)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) SetPinnedAccountChecked(actor, id string, pinned, allowImplicitDefault bool) (core.ActivityCard, error) {
|
||||
card, version, err := s.st.GetCardVersion(actor, id)
|
||||
if err != nil {
|
||||
return card, err
|
||||
}
|
||||
card.Pinned = pinned
|
||||
card, err = s.updateVersionedAccountChecked(actor, card, version, allowImplicitDefault)
|
||||
if err != nil {
|
||||
return card, err
|
||||
}
|
||||
return card, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetStatusAccountChecked(actor, id string, status core.CardStatus, tone, note string, allowImplicitDefault bool) (core.ActivityCard, error) {
|
||||
card, version, err := s.st.GetCardVersion(actor, id)
|
||||
if err != nil {
|
||||
return card, err
|
||||
}
|
||||
card.Status = status
|
||||
if tone != "" {
|
||||
card.Tone = tone
|
||||
}
|
||||
card, err = s.updateVersionedAccountChecked(actor, card, version, allowImplicitDefault)
|
||||
if err != nil {
|
||||
return card, err
|
||||
}
|
||||
return card, s.emitCardAccountChecked(actor, card, core.EvCardStatus,
|
||||
core.CardStatusPayload{CardID: card.ID, Status: status, Tone: card.Tone, Note: note})
|
||||
}
|
||||
|
||||
func (s *Service) DismissAccountChecked(actor, id string, allowImplicitDefault bool) (core.ActivityCard, error) {
|
||||
return s.SetStatusAccountChecked(actor, id, core.CardDismissed, "", "dismissed by user", allowImplicitDefault)
|
||||
}
|
||||
|
||||
func (s *Service) ActionAccountChecked(actor, id, actionID, freeText string, allowImplicitDefault bool) (core.ActivityCard, string, error) {
|
||||
card, version, err := s.st.GetCardVersion(actor, id)
|
||||
if err != nil {
|
||||
return card, "", err
|
||||
}
|
||||
if card.Status != core.CardActionRequired {
|
||||
return card, "", ErrNotAwaiting
|
||||
}
|
||||
label, found := actionID, false
|
||||
for _, action := range card.Actions {
|
||||
if action.ID == actionID {
|
||||
label, found = action.Label, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if actionID == "" && freeText != "" {
|
||||
label = freeText
|
||||
} else {
|
||||
return card, "", ErrNoAction
|
||||
}
|
||||
}
|
||||
card.Status = core.CardResolved
|
||||
card, err = s.updateVersionedAccountChecked(actor, card, version, allowImplicitDefault)
|
||||
if err != nil {
|
||||
return card, "", err
|
||||
}
|
||||
err = s.emitCardAccountChecked(actor, card, core.EvCardResolved,
|
||||
core.CardResolvedPayload{CardID: card.ID, ActionID: actionID, Answer: freeText})
|
||||
return card, label, err
|
||||
}
|
||||
|
||||
// ToProjectAccountChecked performs the copy and dismissal in one transaction.
|
||||
// Events are published after commit through the actor-aware event sink.
|
||||
func (s *Service) ToProjectAccountChecked(actor, id string, allowImplicitDefault bool) (core.ActivityCard, error) {
|
||||
if err := s.requireAccountCheckedEmitter(); err != nil {
|
||||
return core.ActivityCard{}, err
|
||||
}
|
||||
archived, origin, created, err := s.st.ArchiveCardToProjectAccountChecked(actor, id, s.now().UTC(), allowImplicitDefault)
|
||||
if err != nil || !created {
|
||||
return archived, err
|
||||
}
|
||||
if err := s.emitCardAccountChecked(actor, archived, core.EvCardCreated, core.CardPayload{Card: archived}); err != nil {
|
||||
return archived, err
|
||||
}
|
||||
if err := s.emitCardAccountChecked(actor, origin, core.EvCardUpdated, core.CardPayload{Card: origin}); err != nil {
|
||||
return archived, err
|
||||
}
|
||||
err = s.emitCardAccountChecked(actor, origin, core.EvCardStatus,
|
||||
core.CardStatusPayload{CardID: origin.ID, Status: core.CardDismissed, Tone: origin.Tone, Note: "已留档到项目"})
|
||||
return archived, err
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package cardsvc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func TestAccountCheckedCardActionsUseActorEventSinkAndRespectDisable(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/cards.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
if err := st.CreateAccount("admin", "", "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("alice", "", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, err := st.CreateProject("alice", "cards")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conv, err := st.CreateConversation("alice", project.ID, "cards", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyCalls := 0
|
||||
svc := New(st, func(string, string, core.StreamEventType, any) error {
|
||||
legacyCalls++
|
||||
return nil
|
||||
})
|
||||
type emitted struct {
|
||||
actor, channel string
|
||||
typ core.StreamEventType
|
||||
}
|
||||
var guarded []emitted
|
||||
svc.SetAccountCheckedEmitter(func(actor, channel, runID string, typ core.StreamEventType, payload any) error {
|
||||
guarded = append(guarded, emitted{actor, channel, typ})
|
||||
return nil
|
||||
})
|
||||
base := core.ActivityCard{ProjectID: project.ID, ConversationID: conv.ID, Title: "Action",
|
||||
Status: core.CardActionRequired, Actions: []core.UIAction{{ID: "yes", Label: "Approve"}}}
|
||||
action, err := svc.CreateAccountChecked("alice", base, "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, label, err := svc.ActionAccountChecked("alice", action.ID, "yes", "", false); err != nil || label != "Approve" {
|
||||
t.Fatalf("guarded action: label=%q err=%v", label, err)
|
||||
}
|
||||
pending, err := svc.CreateAccountChecked("alice", base, "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pin, err := svc.CreateAccountChecked("alice", core.ActivityCard{ProjectID: project.ID, ConversationID: conv.ID, Title: "Pin"}, "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.SetPinnedAccountChecked("alice", pin.ID, true, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.DismissAccountChecked("alice", pin.ID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive, err := svc.CreateAccountChecked("alice", core.ActivityCard{ProjectID: project.ID, ConversationID: conv.ID, Title: "Archive"}, "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archived, err := svc.ToProjectAccountChecked("alice", archive.ID, false)
|
||||
if err != nil || archived.ConversationID != "" {
|
||||
t.Fatalf("guarded archive: %+v %v", archived, err)
|
||||
}
|
||||
if legacyCalls != 0 {
|
||||
t.Fatalf("guarded card operations used unguarded emitter %d times", legacyCalls)
|
||||
}
|
||||
if len(guarded) == 0 {
|
||||
t.Fatal("guarded operations published no events")
|
||||
}
|
||||
for _, event := range guarded {
|
||||
if event.actor != "alice" {
|
||||
t.Fatalf("event actor lost: %+v", event)
|
||||
}
|
||||
}
|
||||
before := len(guarded)
|
||||
account, err := st.GetAccountByOwner("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, mutate := range []func() error{
|
||||
func() error { _, err := svc.CreateAccountChecked("alice", base, "", false); return err },
|
||||
func() error { _, _, err := svc.ActionAccountChecked("alice", pending.ID, "yes", "", false); return err },
|
||||
func() error { _, err := svc.SetPinnedAccountChecked("alice", pin.ID, false, false); return err },
|
||||
func() error { _, err := svc.DismissAccountChecked("alice", pin.ID, false); return err },
|
||||
func() error { _, err := svc.ToProjectAccountChecked("alice", archive.ID, false); return err },
|
||||
} {
|
||||
if err := mutate(); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled actor mutated card: %v", err)
|
||||
}
|
||||
}
|
||||
if len(guarded) != before {
|
||||
t.Fatalf("disabled actor published %d events", len(guarded)-before)
|
||||
}
|
||||
}
|
||||
+87
-17
@@ -17,25 +17,76 @@ import (
|
||||
// 获得持久化、seq 排序、断线重放与终态对账(Stream 契约)。
|
||||
type Emitter func(conversationID, runID string, typ core.StreamEventType, payload any) error
|
||||
|
||||
// AccountCheckedEmitter persists an actor-attributed event through its own
|
||||
// final account-checked Store transaction. The card write and event emission
|
||||
// are separate commits; the application wires this to its guarded event sink.
|
||||
type AccountCheckedEmitter func(actor, conversationID, runID string, typ core.StreamEventType, payload any) error
|
||||
|
||||
var (
|
||||
ErrNotFound = store.ErrNotFound // 语义对齐存储层:跨租户/不存在一律 NotFound
|
||||
ErrNoAction = errors.New("card: action not found")
|
||||
ErrNotAwaiting = errors.New("card: not awaiting action")
|
||||
ErrNotFound = store.ErrNotFound // 语义对齐存储层:跨租户/不存在一律 NotFound
|
||||
ErrNoAction = errors.New("card: action not found")
|
||||
ErrNotAwaiting = errors.New("card: not awaiting action")
|
||||
ErrGuardedEmitterRequired = errors.New("card: account-checked emitter required")
|
||||
)
|
||||
|
||||
// cardAppendWindow 是流式追加(Append)的 card.updated 发布节流窗口(D45)。
|
||||
const cardAppendWindow = 250 * time.Millisecond
|
||||
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
emit Emitter
|
||||
now func() time.Time
|
||||
emitMu sync.Mutex
|
||||
lastEmit map[string]time.Time // 流式追加的最近发布时刻(按卡)
|
||||
st *store.Store
|
||||
emit Emitter
|
||||
accountCheckedEmit AccountCheckedEmitter
|
||||
projectSharedEmit ProjectSharedEmitter
|
||||
// MirrorConvCards EXT-12:会话卡是否镜像进项目公共流(缺省 true=现状全量
|
||||
// 镜像;false 时会话卡只进本人会话流,项目级卡不受影响)。
|
||||
MirrorConvCards bool
|
||||
now func() time.Time
|
||||
emitMu sync.Mutex
|
||||
lastEmit map[string]time.Time // 流式追加的最近发布时刻(按卡)
|
||||
}
|
||||
|
||||
func New(st *store.Store, emit Emitter) *Service {
|
||||
return &Service{st: st, emit: emit, now: time.Now, lastEmit: map[string]time.Time{}}
|
||||
return &Service{st: st, emit: emit, now: time.Now, lastEmit: map[string]time.Time{}, MirrorConvCards: true}
|
||||
}
|
||||
|
||||
func (s *Service) SetAccountCheckedEmitter(emit AccountCheckedEmitter) {
|
||||
s.accountCheckedEmit = emit
|
||||
}
|
||||
|
||||
// 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):
|
||||
@@ -52,6 +103,11 @@ func (s *Service) emitCard(c core.ActivityCard, typ core.StreamEventType, payloa
|
||||
if err := s.emit(c.ConversationID, c.SourceRunID, typ, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
// EXT-12 最小开关:会话卡不镜像进项目公共流(不发布即不持久,
|
||||
// 重放天然不含会话卡);项目级卡(无会话归属)不受影响。
|
||||
if !s.MirrorConvCards {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if c.ProjectID != "" {
|
||||
return s.emit(string(core.ProjectChannelKey(c.ProjectID)), c.SourceRunID, typ, payload)
|
||||
@@ -87,6 +143,7 @@ func (s *Service) List(owner, conversationID string) ([]core.ActivityCard, error
|
||||
}
|
||||
|
||||
// Update 保存整卡变更(流式 Body 增量等)并发布 card.updated;ref 保持原值。
|
||||
// 系统镜像路径(同会话 run 的顺序行为)沿用盲写;用户态裁决走 UpdateVersioned。
|
||||
func (s *Service) Update(owner string, card core.ActivityCard) error {
|
||||
card.UpdatedAt = s.now().UTC()
|
||||
if err := s.st.SaveCard(owner, &card, ""); err != nil {
|
||||
@@ -95,6 +152,16 @@ func (s *Service) Update(owner string, card core.ActivityCard) error {
|
||||
return s.emitCard(card, core.EvCardUpdated, core.CardPayload{Card: card})
|
||||
}
|
||||
|
||||
// UpdateVersioned 乐观并发保存(U3,KTD7):版本不匹配透传 store.ErrConflict,
|
||||
// HTTP 层映射 409;客户端重读卡后重试。
|
||||
func (s *Service) UpdateVersioned(owner string, card core.ActivityCard, expectedVersion int) error {
|
||||
card.UpdatedAt = s.now().UTC()
|
||||
if err := s.st.SaveCardVersioned(owner, &card, "", expectedVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.emitCard(card, core.EvCardUpdated, core.CardPayload{Card: card})
|
||||
}
|
||||
|
||||
// Append 流式追加正文(card_append 工具;D45):文本并入 body 尾部 markdown
|
||||
// 节点(尾节点非 markdown 则新增),逐次持久化;card.updated 按 cardAppendWindow
|
||||
// 节流——窗口内的追加只落库不发布(投影语义:事件携带整卡,后续事件自然覆盖
|
||||
@@ -133,7 +200,7 @@ func (s *Service) Append(owner, id, text string) error {
|
||||
|
||||
// SetStatus 状态迁移:更新并发布 card.status。
|
||||
func (s *Service) SetStatus(owner, id string, status core.CardStatus, tone, note string) (core.ActivityCard, error) {
|
||||
c, err := s.st.GetCard(owner, id)
|
||||
c, ver, err := s.st.GetCardVersion(owner, id)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
@@ -141,7 +208,7 @@ func (s *Service) SetStatus(owner, id string, status core.CardStatus, tone, note
|
||||
if tone != "" {
|
||||
c.Tone = tone
|
||||
}
|
||||
if err := s.Update(owner, c); err != nil {
|
||||
if err := s.UpdateVersioned(owner, c, ver); err != nil {
|
||||
return c, err
|
||||
}
|
||||
return c, s.emitCard(c, core.EvCardStatus, core.CardStatusPayload{CardID: c.ID, Status: status, Tone: c.Tone, Note: note})
|
||||
@@ -149,12 +216,12 @@ func (s *Service) SetStatus(owner, id string, status core.CardStatus, tone, note
|
||||
|
||||
// SetPinned 置顶/取消(收件箱"不沉没"语义)。
|
||||
func (s *Service) SetPinned(owner, id string, pinned bool) (core.ActivityCard, error) {
|
||||
c, err := s.st.GetCard(owner, id)
|
||||
c, ver, err := s.st.GetCardVersion(owner, id)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
c.Pinned = pinned
|
||||
return c, s.Update(owner, c)
|
||||
return c, s.UpdateVersioned(owner, c, ver)
|
||||
}
|
||||
|
||||
// Dismiss 关闭卡(非确认语义,留痕)。
|
||||
@@ -165,7 +232,7 @@ func (s *Service) Dismiss(owner, id string) (core.ActivityCard, error) {
|
||||
// Action 处理卡上按钮点击:仅 action_required 卡可裁决;结果 resolved 并发布
|
||||
// card.resolved(ActionID 即按钮语义)。
|
||||
func (s *Service) Action(owner, id, actionID, freeText string) (core.ActivityCard, string, error) {
|
||||
c, err := s.st.GetCard(owner, id)
|
||||
c, ver, err := s.st.GetCardVersion(owner, id)
|
||||
if err != nil {
|
||||
return c, "", err
|
||||
}
|
||||
@@ -189,7 +256,7 @@ func (s *Service) Action(owner, id, actionID, freeText string) (core.ActivityCar
|
||||
return c, "", ErrNoAction
|
||||
}
|
||||
c.Status = core.CardResolved
|
||||
if err := s.Update(owner, c); err != nil {
|
||||
if err := s.UpdateVersioned(owner, c, ver); err != nil {
|
||||
return c, "", err
|
||||
}
|
||||
return c, label, s.emitCard(c, core.EvCardResolved, core.CardResolvedPayload{CardID: c.ID, ActionID: actionID, Answer: freeText})
|
||||
@@ -242,7 +309,7 @@ func (s *Service) ResolveByRef(owner, ref, actionID, answer string) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
c, err := s.st.GetCard(owner, id)
|
||||
c, ver, err := s.st.GetCardVersion(owner, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -250,7 +317,10 @@ func (s *Service) ResolveByRef(owner, ref, actionID, answer string) error {
|
||||
return nil // 已决(双现任一侧先到)
|
||||
}
|
||||
c.Status = core.CardResolved
|
||||
if err := s.Update(owner, c); err != nil {
|
||||
if err := s.UpdateVersioned(owner, c, ver); err != nil {
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
return nil // 双现任一侧先到(版本竞争败者):不重试,卡以先到侧为准
|
||||
}
|
||||
return err
|
||||
}
|
||||
if c.ConversationID != "" && s.emit != nil {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package cardsvc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// EXT-12 最小开关(红测):MirrorConvCards=false 时会话卡不镜像进项目公共流
|
||||
// (不发布即不持久,重放天然不含会话卡);项目级卡不受影响;缺省 true 保持现状。
|
||||
func TestConvCardMirrorSwitch(t *testing.T) {
|
||||
newSvc := func(mirror bool) (*Service, *[]string) {
|
||||
chans := []string{}
|
||||
emit := func(convID, runID string, typ core.StreamEventType, payload any) error {
|
||||
chans = append(chans, convID)
|
||||
return nil
|
||||
}
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/cards.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
svc := New(st, emit)
|
||||
svc.MirrorConvCards = mirror
|
||||
return svc, &chans
|
||||
}
|
||||
card := core.ActivityCard{ConversationID: "c-priv", ProjectID: "p-1", Kind: core.CardKindProgress, Title: "私有过程卡"}
|
||||
|
||||
// 开(缺省现状):会话流 + 项目流双发。
|
||||
svc, chans := newSvc(true)
|
||||
if _, err := svc.Create("alice", card, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*chans) != 2 {
|
||||
t.Fatalf("mirror on: emitted %v, want 会话流+项目流", *chans)
|
||||
}
|
||||
|
||||
// 关:只进本人会话流,项目公共流零事件。
|
||||
svc2, chans2 := newSvc(false)
|
||||
if _, err := svc2.Create("alice", card, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*chans2) != 1 || (*chans2)[0] != "c-priv" {
|
||||
t.Fatalf("mirror off: emitted %v, want 仅会话流 c-priv(项目流不得发布)", *chans2)
|
||||
}
|
||||
|
||||
// 项目级卡(无会话归属)不受开关影响。
|
||||
pc := core.ActivityCard{ProjectID: "p-1", Kind: core.CardKindProgress, Title: "项目级卡"}
|
||||
if _, err := svc2.Create("alice", pc, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*chans2) != 2 || (*chans2)[1] != string(core.ProjectChannelKey("p-1")) {
|
||||
t.Fatalf("project-level card must still publish: %v", *chans2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package cmdbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentcommand "agent-command"
|
||||
)
|
||||
|
||||
func TestEffectiveAccountGuardRejectsBeforeDispatchEffects(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
req Request
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{"disabled handler", Request{ID: "demo.ping", Owner: "alice", Actor: "alice"}, errors.New("disabled"), "alice"},
|
||||
{"failed lookup", Request{ID: "demo.ping", Owner: "alice", Actor: "alice"}, errors.New("database unavailable"), "alice"},
|
||||
{"plugin actor uses account owner", Request{ID: "demo.ping", Owner: "alice", Actor: "plugin:rdm"}, errors.New("disabled"), "alice"},
|
||||
{"actor fallback", Request{ID: "demo.ping", Actor: "alice"}, errors.New("disabled"), "alice"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
audit, cards := &memAudit{}, &memCards{}
|
||||
b := newTestBus(audit, cards)
|
||||
called := 0
|
||||
b.SetEffectiveAccountGuard(func(_ context.Context, actor string) error {
|
||||
called++
|
||||
if actor != tc.want {
|
||||
t.Fatalf("guard actor = %q, want %q", actor, tc.want)
|
||||
}
|
||||
return tc.err
|
||||
})
|
||||
resp := b.Dispatch(context.Background(), tc.req)
|
||||
if resp.Receipt.State != agentcommand.StateRejected || !strings.Contains(resp.Receipt.Reason, "effective account") {
|
||||
t.Fatalf("guard rejection: %+v", resp.Receipt)
|
||||
}
|
||||
if called != 1 || len(audit.logs) != 0 || len(cards.created) != 0 {
|
||||
t.Fatalf("guard calls=%d audits=%d cards=%d", called, len(audit.logs), len(cards.created))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveAccountGuardRejectsBeforePendingConfirmAndRechecks(t *testing.T) {
|
||||
audit, cards := &memAudit{}, &memCards{}
|
||||
b := newTestBus(audit, cards)
|
||||
allowed := false
|
||||
b.SetEffectiveAccountGuard(func(_ context.Context, actor string) error {
|
||||
if actor != "alice" {
|
||||
t.Fatalf("guard actor = %q", actor)
|
||||
}
|
||||
if !allowed {
|
||||
return errors.New("disabled")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
req := Request{ID: "demo.risky", Owner: "alice", Actor: "alice", ConversationID: "cnv_1", Params: map[string]any{"target": "prod"}}
|
||||
if r := b.Dispatch(context.Background(), req); r.Receipt.State != agentcommand.StateRejected {
|
||||
t.Fatalf("disabled dispatch: %+v", r.Receipt)
|
||||
}
|
||||
if len(cards.created) != 0 || len(b.pending) != 0 || len(audit.logs) != 0 {
|
||||
t.Fatal("disabled dispatch created confirmation effects")
|
||||
}
|
||||
allowed = true
|
||||
r := b.Dispatch(context.Background(), req)
|
||||
if r.Receipt.State != agentcommand.StateAccepted || len(cards.created) != 1 || len(b.pending) != 1 {
|
||||
t.Fatalf("enabled dispatch: %+v", r.Receipt)
|
||||
}
|
||||
allowed = false
|
||||
before := len(audit.logs)
|
||||
if r := b.Confirm("alice", "card_x1", "confirm"); r.Receipt.State != agentcommand.StateRejected {
|
||||
t.Fatalf("disabled confirm: %+v", r.Receipt)
|
||||
}
|
||||
if len(b.pending) != 1 || len(audit.logs) != before {
|
||||
t.Fatal("disabled confirm consumed pending request or wrote audit")
|
||||
}
|
||||
allowed = true
|
||||
if r := b.Confirm("alice", "card_x1", "confirm"); r.Receipt.State != agentcommand.StateCommitted {
|
||||
t.Fatalf("re-enabled confirm: %+v", r.Receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveAccountGuardStopsConfirmedHandlerAfterStateChanges(t *testing.T) {
|
||||
b := New(&memAudit{}, &memCards{}, nil)
|
||||
executed := 0
|
||||
if err := b.RegisterPluginCommand(agentcommand.CommandSpec{ID: "demo.effect", Title: "Effect"}, func(context.Context, Request) (any, error) {
|
||||
executed++
|
||||
return "done", nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checks := 0
|
||||
b.SetEffectiveAccountGuard(func(_ context.Context, actor string) error {
|
||||
checks++
|
||||
if actor != "alice" {
|
||||
t.Fatalf("guard actor = %q", actor)
|
||||
}
|
||||
if checks == 4 {
|
||||
return errors.New("disabled after confirmation started")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
// Confirm passes its own check, then Dispatch must check once more before
|
||||
// invoking the handler. A direct confirmed request takes the same path.
|
||||
if err := b.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.high", Title: "High", Danger: agentcommand.DangerHigh}, "", func(context.Context, Request) (any, error) {
|
||||
executed++
|
||||
return "done", nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := b.Dispatch(context.Background(), Request{ID: "demo.high", Owner: "alice", ConversationID: "cnv_1"})
|
||||
if r.Receipt.State != agentcommand.StateAccepted {
|
||||
t.Fatalf("initial dispatch: %+v", r.Receipt)
|
||||
}
|
||||
// Dispatch checks again before creating the confirmation card. Confirm's
|
||||
// check passes; its nested Dispatch catches the fourth-state change.
|
||||
if r := b.Confirm("alice", "card_x1", "confirm"); r.Receipt.State != agentcommand.StateRejected {
|
||||
t.Fatalf("confirm after disable: %+v", r.Receipt)
|
||||
}
|
||||
if executed != 0 || len(b.pending) != 0 {
|
||||
t.Fatalf("executed=%d pending=%d", executed, len(b.pending))
|
||||
}
|
||||
// Direct confirmed execution also passes through the same guard.
|
||||
checks = 3
|
||||
if r := b.Dispatch(context.Background(), Request{ID: "demo.effect", Owner: "alice", Confirmed: true}); r.Receipt.State != agentcommand.StateRejected || executed != 0 {
|
||||
t.Fatalf("direct confirmed dispatch: %+v executed=%d", r.Receipt, executed)
|
||||
}
|
||||
}
|
||||
|
||||
type trackingPendingStore struct {
|
||||
owner, payload string
|
||||
takes int
|
||||
}
|
||||
|
||||
func (p *trackingPendingStore) SavePendingConfirm(_ string, owner, payload string) error {
|
||||
p.owner, p.payload = owner, payload
|
||||
return nil
|
||||
}
|
||||
func (p *trackingPendingStore) TakePendingConfirm(string) (string, string, bool, error) {
|
||||
p.takes++
|
||||
return p.owner, p.payload, p.payload != "", nil
|
||||
}
|
||||
|
||||
func TestEffectiveAccountGuardDoesNotConsumeStoredPendingOnDeniedConfirm(t *testing.T) {
|
||||
b := newTestBus(&memAudit{}, &memCards{})
|
||||
pendingStorage := &trackingPendingStore{}
|
||||
b.SetPendingStore(pendingStorage)
|
||||
allowed := true
|
||||
b.SetEffectiveAccountGuard(func(_ context.Context, actor string) error {
|
||||
if actor != "alice" {
|
||||
t.Fatalf("guard actor = %q", actor)
|
||||
}
|
||||
if !allowed {
|
||||
return errors.New("database unavailable")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
r := b.Dispatch(context.Background(), Request{ID: "demo.risky", Owner: "alice", ConversationID: "cnv_1", Params: map[string]any{"target": "prod"}})
|
||||
if r.Receipt.State != agentcommand.StateAccepted || pendingStorage.payload == "" {
|
||||
t.Fatalf("pending setup: %+v", r.Receipt)
|
||||
}
|
||||
b.pending = map[string]pending{} // force the restored path
|
||||
allowed = false
|
||||
if r := b.Confirm("alice", "card_x1", "confirm"); r.Receipt.State != agentcommand.StateRejected || pendingStorage.takes != 0 {
|
||||
t.Fatalf("denied confirm: %+v takes=%d", r.Receipt, pendingStorage.takes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package cmdbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
agentcommand "agent-command"
|
||||
)
|
||||
|
||||
func TestStrictAccountWritesRejectLegacyBusPorts(t *testing.T) {
|
||||
audit, cards := &memAudit{}, &memCards{}
|
||||
b := newTestBus(audit, cards)
|
||||
b.RequireAccountCheckedWrites(false)
|
||||
resp := b.Dispatch(context.Background(), Request{ID: "demo.risky", Owner: "alice", Actor: "alice", ConversationID: "cnv_1", Params: map[string]any{"target": "prod"}})
|
||||
if resp.Receipt.State != agentcommand.StateUnknown || len(cards.created) != 0 || len(audit.logs) != 0 || len(b.pending) != 0 {
|
||||
t.Fatalf("legacy port wrote under strict mode: %+v, cards=%d audit=%d pending=%d", resp.Receipt, len(cards.created), len(audit.logs), len(b.pending))
|
||||
}
|
||||
}
|
||||
+253
-35
@@ -18,9 +18,11 @@ import (
|
||||
"agent-system-core"
|
||||
)
|
||||
|
||||
// AuditStore 审计落盘端口(store 实现;v7 command_audit)。
|
||||
// AuditStore 审计落盘端口(store 实现;v7 建表、v18 加 owner 列)。
|
||||
// owner 是请求租户(req.Owner):审计行按租户归属,查询按 owner 过滤,
|
||||
// 跨租户审计不可见(信封 Actor 是发起主体,不作归属依据)。
|
||||
type AuditStore interface {
|
||||
AppendAudit(env agentcommand.AuditEnvelope) error
|
||||
AppendAudit(owner string, env agentcommand.AuditEnvelope) error
|
||||
}
|
||||
|
||||
// CardPort 确认卡所需卡片能力(cardsvc.Service 实现)。
|
||||
@@ -32,6 +34,60 @@ type CardPort interface {
|
||||
// Handler 宿主执行适配器:同步内置用例返回 payload;异步执行自行返回 accepted 回执。
|
||||
type Handler func(ctx context.Context, req Request) (any, error)
|
||||
|
||||
// CommandTool EXT-14:带 exposeAsTool 声明的命令的工具投影视图(runmgr 消费)。
|
||||
// Risk 取 low|medium|high(工具权限声明口径);ID 即工具名(命令 id)。
|
||||
type CommandTool struct {
|
||||
ID string
|
||||
Title string
|
||||
Description string
|
||||
Params json.RawMessage
|
||||
Risk string
|
||||
}
|
||||
|
||||
// CommandTools 全部带 ExposeAsTool 的已启用命令(EXT-14 工具投影源)。
|
||||
// ToolGate 是 owner 级工具注入闸(U3:禁用即不注入能力):返回 false 时该命令
|
||||
// 不投影给该 owner 的模型。组合根按需装配(如 generative-ui 按 activation 台账)。
|
||||
type ToolGate func(owner, commandID string) bool
|
||||
|
||||
// EffectiveAccountGuard checks the current account state before a command can
|
||||
// create a confirmation or invoke a handler. The composition root supplies the
|
||||
// check; nil keeps standalone and auth-off fixtures unchanged. The actor is
|
||||
// derived only from trusted Request identity fields, never Params. This is an
|
||||
// early check, not an atomic fence across handler databases or external calls.
|
||||
type EffectiveAccountGuard func(ctx context.Context, actor string) error
|
||||
|
||||
// SetToolGate 装配 owner 级注入闸(组合根;启动期一次)。
|
||||
func (b *Bus) SetToolGate(g ToolGate) { b.toolGate = g }
|
||||
|
||||
func (b *Bus) CommandTools() []CommandTool {
|
||||
return b.CommandToolsFor("")
|
||||
}
|
||||
|
||||
// CommandToolsFor 按 owner 过滤工具投影(gate 为 nil 时不过滤;owner 为空时
|
||||
// gated 命令一律不投影——fail-closed)。
|
||||
func (b *Bus) CommandToolsFor(owner string) []CommandTool {
|
||||
out := []CommandTool{}
|
||||
for _, r := range b.reg.List() {
|
||||
if !r.Enabled || r.Spec.ExposeAsTool == nil {
|
||||
continue
|
||||
}
|
||||
if b.toolGate != nil && !b.toolGate(owner, r.Spec.ID) {
|
||||
continue
|
||||
}
|
||||
risk := r.Spec.ExposeAsTool.Risk
|
||||
if risk == "" {
|
||||
risk = "low"
|
||||
}
|
||||
desc := r.Spec.Description
|
||||
if desc == "" {
|
||||
desc = r.Spec.Title
|
||||
}
|
||||
out = append(out, CommandTool{ID: r.Spec.ID, Title: r.Spec.Title,
|
||||
Description: desc, Params: r.Spec.ParamsSchema, Risk: risk})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Request 一次分发请求(入口归一后的统一形态)。
|
||||
type Request struct {
|
||||
ID string
|
||||
@@ -42,6 +98,7 @@ type Request struct {
|
||||
ConversationID string
|
||||
AnchorMessageID string // 确认卡锚定的发起消息(内联渲染)
|
||||
Confirmed bool // 危险命令经确认卡放行后的执行
|
||||
HostProjectID string // EXT-18:分发所属基座项目(空=无项目上下文)
|
||||
}
|
||||
|
||||
// Response 分发结果:回执 + 处理负载 + 可能的确认卡。
|
||||
@@ -58,15 +115,20 @@ type pending struct {
|
||||
}
|
||||
|
||||
type Bus struct {
|
||||
reg *agentcommand.Registry
|
||||
audit AuditStore
|
||||
cards CardPort
|
||||
now func() time.Time
|
||||
handlers map[string]Handler
|
||||
slash map[string]string // "/x" → command id
|
||||
pendMu sync.Mutex
|
||||
pending map[string]pending
|
||||
pendingStore PendingStore
|
||||
reg *agentcommand.Registry
|
||||
audit AuditStore
|
||||
cards CardPort
|
||||
now func() time.Time
|
||||
handlers map[string]Handler
|
||||
slash map[string]string // "/x" → command id
|
||||
pendMu sync.Mutex
|
||||
pending map[string]pending
|
||||
pendingStore PendingStore
|
||||
intents *IntentStore // G2-2:意图声明表(自然语言路由)
|
||||
toolGate ToolGate // U3:owner 级工具注入闸(nil = 不过滤)
|
||||
accountGuard EffectiveAccountGuard // A1: optional command execution precheck
|
||||
strictAccountWrites bool
|
||||
allowImplicitLocal bool
|
||||
}
|
||||
|
||||
// PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。
|
||||
@@ -82,6 +144,7 @@ func New(audit AuditStore, cards CardPort, now func() time.Time) *Bus {
|
||||
return &Bus{
|
||||
reg: agentcommand.NewRegistry(), audit: audit, cards: cards, now: now,
|
||||
handlers: map[string]Handler{}, slash: map[string]string{}, pending: map[string]pending{},
|
||||
intents: NewIntentStore(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +154,70 @@ func (b *Bus) Registry() *agentcommand.Registry { return b.reg }
|
||||
// SetPendingStore 注入确认挂起态的持久化实现(nil = 仅进程内)。
|
||||
func (b *Bus) SetPendingStore(st PendingStore) { b.pendingStore = st }
|
||||
|
||||
// SetEffectiveAccountGuard installs the composition-root check at startup.
|
||||
// Handler Store writes still need their own final transaction fence.
|
||||
func (b *Bus) SetEffectiveAccountGuard(g EffectiveAccountGuard) { b.accountGuard = g }
|
||||
|
||||
// RequireAccountCheckedWrites makes every Bus-owned durable sink use a final
|
||||
// account transaction. The composition root enables this after wiring Store
|
||||
// and Cards; standalone in-memory fixtures retain their legacy ports.
|
||||
func (b *Bus) RequireAccountCheckedWrites(allowImplicitLocal bool) {
|
||||
b.strictAccountWrites = true
|
||||
b.allowImplicitLocal = allowImplicitLocal
|
||||
}
|
||||
|
||||
func (b *Bus) createConfirmCard(actor string, card core.ActivityCard, ref string) (core.ActivityCard, error) {
|
||||
if !b.strictAccountWrites {
|
||||
return b.cards.Create(actor, card, ref)
|
||||
}
|
||||
checked, ok := b.cards.(interface {
|
||||
CreateAccountChecked(string, core.ActivityCard, string, bool) (core.ActivityCard, error)
|
||||
})
|
||||
if !ok {
|
||||
return core.ActivityCard{}, fmt.Errorf("cmdbus: account-checked card port required")
|
||||
}
|
||||
return checked.CreateAccountChecked(actor, card, ref, b.allowImplicitLocal)
|
||||
}
|
||||
|
||||
func (b *Bus) setConfirmCardStatus(actor, id string, status core.CardStatus, tone, note string) (core.ActivityCard, error) {
|
||||
if !b.strictAccountWrites {
|
||||
return b.cards.SetStatus(actor, id, status, tone, note)
|
||||
}
|
||||
checked, ok := b.cards.(interface {
|
||||
SetStatusAccountChecked(string, string, core.CardStatus, string, string, bool) (core.ActivityCard, error)
|
||||
})
|
||||
if !ok {
|
||||
return core.ActivityCard{}, fmt.Errorf("cmdbus: account-checked card port required")
|
||||
}
|
||||
return checked.SetStatusAccountChecked(actor, id, status, tone, note, b.allowImplicitLocal)
|
||||
}
|
||||
|
||||
func (b *Bus) savePending(cardID, owner, payload string) error {
|
||||
if !b.strictAccountWrites {
|
||||
return b.pendingStore.SavePendingConfirm(cardID, owner, payload)
|
||||
}
|
||||
checked, ok := b.pendingStore.(interface {
|
||||
SavePendingConfirmAccountChecked(string, string, string, bool) error
|
||||
})
|
||||
if !ok {
|
||||
return fmt.Errorf("cmdbus: account-checked pending store required")
|
||||
}
|
||||
return checked.SavePendingConfirmAccountChecked(cardID, owner, payload, b.allowImplicitLocal)
|
||||
}
|
||||
|
||||
func (b *Bus) takePending(cardID, owner string) (string, string, bool, error) {
|
||||
if !b.strictAccountWrites {
|
||||
return b.pendingStore.TakePendingConfirm(cardID)
|
||||
}
|
||||
checked, ok := b.pendingStore.(interface {
|
||||
TakePendingConfirmAccountChecked(string, string, bool) (string, string, bool, error)
|
||||
})
|
||||
if !ok {
|
||||
return "", "", false, fmt.Errorf("cmdbus: account-checked pending store required")
|
||||
}
|
||||
return checked.TakePendingConfirmAccountChecked(cardID, owner, b.allowImplicitLocal)
|
||||
}
|
||||
|
||||
// RegisterBuiltin 登记内置命令并绑定宿主处理器与 `/` 别名。
|
||||
func (b *Bus) RegisterBuiltin(spec agentcommand.CommandSpec, slash string, h Handler) error {
|
||||
spec.Binding = agentcommand.Binding{Kind: agentcommand.BindBuiltin}
|
||||
@@ -136,10 +263,19 @@ func (b *Bus) RegisterPluginCommands(overlay []json.RawMessage) error {
|
||||
if err := b.reg.Register(spec, agentcommand.SourcePlugin); err != nil {
|
||||
return fmt.Errorf("cmdbus: register %s: %w", spec.ID, err)
|
||||
}
|
||||
b.bindPluginSlash(spec)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bindPluginSlash EXT-10②:插件命令规格自带的 slash 别名绑定(与内置命令同一张
|
||||
// 别名表——四入口同源:slash 命中即同一命令 id、同一审计信封)。
|
||||
func (b *Bus) bindPluginSlash(spec agentcommand.CommandSpec) {
|
||||
if spec.Slash != "" {
|
||||
b.slash[strings.ToLower(spec.Slash)] = spec.ID
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterPluginCommand 注册进程内插件命令(EXT-1):登记规格并绑定宿主处理器。
|
||||
// 仅登记规格的声明式路径仍走 RegisterPluginCommands(无 handler,分发即 rejected)。
|
||||
func (b *Bus) RegisterPluginCommand(spec agentcommand.CommandSpec, h Handler) error {
|
||||
@@ -149,9 +285,13 @@ func (b *Bus) RegisterPluginCommand(spec agentcommand.CommandSpec, h Handler) er
|
||||
if h != nil {
|
||||
b.handlers[spec.ID] = h
|
||||
}
|
||||
b.bindPluginSlash(spec)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Intents 意图声明表(G2-2:注册面与路由查询共用)。
|
||||
func (b *Bus) Intents() *IntentStore { return b.intents }
|
||||
|
||||
// ResolveSlash `/` 入口解析:返回命令 id 与原始字段(参数由宿主处理器按命令语义解析)。
|
||||
func (b *Bus) ResolveSlash(cmd string) (string, bool) {
|
||||
id, ok := b.slash[strings.ToLower(strings.TrimSpace(cmd))]
|
||||
@@ -176,10 +316,16 @@ func (b *Bus) HasCommand(id string) bool {
|
||||
// List 四入口共享的命令清单(`/` 菜单、按钮、卡片 action 提示用)。
|
||||
func (b *Bus) List() []agentcommand.Registered { return b.reg.List() }
|
||||
|
||||
// Dispatch 统一分发:校验→危险收口→执行→审计。任何路径都产出回执与审计(入口对称)。
|
||||
// Dispatch 统一分发:账号预检→校验→危险收口→执行→审计。账号预检拒绝只返回回执,
|
||||
// 避免停用账号或检查故障时继续写入审计;其余路径维持四入口审计对称。
|
||||
func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
|
||||
start := b.now()
|
||||
req.Entrance = agentcommand.CanonicalEntrance(req.Entrance)
|
||||
if err := b.checkEffectiveAccount(ctx, accountActor(req)); err != nil {
|
||||
// A rejected account cannot create a card, pending row, audit row, or
|
||||
// invoke a handler. A failed read is also a denial.
|
||||
return b.accountRejected(req.ID, start)
|
||||
}
|
||||
receipt := func(state agentcommand.ResultState, op, reason string) Response {
|
||||
return Response{Receipt: b.auditReceipt(req, state, op, reason, start)}
|
||||
}
|
||||
@@ -195,17 +341,24 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
|
||||
if b.cards == nil || req.ConversationID == "" {
|
||||
return receipt(agentcommand.StateRejected, "", "high-danger command requires conversation context for inline confirm")
|
||||
}
|
||||
card, err := b.cards.Create(req.Owner, confirmCard(spec, req), "cmd:"+spec.ID)
|
||||
if err := b.checkEffectiveAccount(ctx, accountActor(req)); err != nil {
|
||||
return b.accountRejected(req.ID, start)
|
||||
}
|
||||
card, err := b.createConfirmCard(accountActor(req), confirmCard(spec, req), "cmd:"+spec.ID)
|
||||
if err != nil {
|
||||
return receipt(agentcommand.StateUnknown, "", "confirm card: "+err.Error())
|
||||
}
|
||||
if b.pendingStore != nil {
|
||||
raw, _ := json.Marshal(req)
|
||||
if err := b.savePending(card.ID, accountActor(req), string(raw)); err != nil {
|
||||
return receipt(agentcommand.StateUnknown, "", "confirm pending: "+err.Error())
|
||||
}
|
||||
} else if b.strictAccountWrites {
|
||||
return receipt(agentcommand.StateUnknown, "", "confirm pending: account-checked store required")
|
||||
}
|
||||
b.pendMu.Lock()
|
||||
b.pending[card.ID] = pending{req: req, cardID: card.ID}
|
||||
b.pendMu.Unlock()
|
||||
if b.pendingStore != nil {
|
||||
raw, _ := json.Marshal(req)
|
||||
_ = b.pendingStore.SavePendingConfirm(card.ID, req.Owner, string(raw))
|
||||
}
|
||||
r := b.auditReceipt(req, agentcommand.StateAccepted, card.ID, "awaiting explicit confirm", start)
|
||||
return Response{Receipt: r, Card: &card}
|
||||
}
|
||||
@@ -213,6 +366,9 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
|
||||
if !ok {
|
||||
return receipt(agentcommand.StateRejected, "", "no host handler bound for "+req.ID)
|
||||
}
|
||||
if err := b.checkEffectiveAccount(ctx, accountActor(req)); err != nil {
|
||||
return b.accountRejected(req.ID, start)
|
||||
}
|
||||
payload, err := h(ctx, req)
|
||||
if err != nil {
|
||||
return receipt(agentcommand.StateRejected, "", "rejected: "+err.Error())
|
||||
@@ -223,49 +379,98 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
|
||||
|
||||
// Confirm 确认卡回道:actionID=confirm 执行挂起请求(entrance 归一 card),其余视为取消。
|
||||
func (b *Bus) Confirm(owner, cardID, actionID string) Response {
|
||||
return b.ConfirmContext(context.Background(), owner, cardID, actionID)
|
||||
}
|
||||
|
||||
// ConfirmContext carries the request cancellation through the confirmed
|
||||
// handler and repeats the account check before consuming durable state.
|
||||
func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string) Response {
|
||||
// The caller supplies the authenticated card owner. Check before even
|
||||
// consuming a persisted pending request; Dispatch checks the stored
|
||||
// request again immediately before executing its handler.
|
||||
if err := b.checkEffectiveAccount(ctx, owner); err != nil {
|
||||
return b.accountRejected("unknown", b.now())
|
||||
}
|
||||
b.pendMu.Lock()
|
||||
p, ok := b.pending[cardID]
|
||||
if ok && actionID == "confirm" {
|
||||
delete(b.pending, cardID)
|
||||
}
|
||||
b.pendMu.Unlock()
|
||||
if ok && actionID == "confirm" && b.pendingStore != nil {
|
||||
_, _, _, _ = b.pendingStore.TakePendingConfirm(cardID)
|
||||
if ok && p.req.Owner != "" && p.req.Owner != owner {
|
||||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: p.req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", Time: b.now().UTC()}}
|
||||
}
|
||||
if !ok && b.pendingStore != nil {
|
||||
_, payload, found, err := b.pendingStore.TakePendingConfirm(cardID)
|
||||
if err == nil && found {
|
||||
var req Request
|
||||
if json.Unmarshal([]byte(payload), &req) == nil {
|
||||
p = pending{req: req, cardID: cardID}
|
||||
ok = true
|
||||
}
|
||||
if b.pendingStore != nil {
|
||||
storedOwner, payload, found, err := b.takePending(cardID, owner)
|
||||
if err != nil {
|
||||
return b.accountRejected("unknown", b.now())
|
||||
}
|
||||
if found {
|
||||
var req Request
|
||||
if storedOwner != owner || json.Unmarshal([]byte(payload), &req) != nil {
|
||||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "invalid pending confirm", Time: b.now().UTC()}}
|
||||
}
|
||||
p = pending{req: req, cardID: cardID}
|
||||
ok = true
|
||||
} else if b.strictAccountWrites {
|
||||
ok = false
|
||||
}
|
||||
} else if b.strictAccountWrites {
|
||||
ok = false
|
||||
}
|
||||
if !ok {
|
||||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "no pending confirm (expired or already resolved)", Time: b.now().UTC()}}
|
||||
}
|
||||
req := p.req
|
||||
if req.Owner != "" && req.Owner != owner {
|
||||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", Time: b.now().UTC()}}
|
||||
}
|
||||
b.pendMu.Lock()
|
||||
delete(b.pending, cardID)
|
||||
b.pendMu.Unlock()
|
||||
req.Confirmed = true
|
||||
req.Entrance = "card"
|
||||
if actionID != "confirm" {
|
||||
if b.cards != nil {
|
||||
_, _ = b.cards.SetStatus(owner, cardID, core.CardResolved, "muted", "cancelled")
|
||||
_, _ = b.setConfirmCardStatus(owner, cardID, core.CardResolved, "muted", "cancelled")
|
||||
}
|
||||
start := b.now()
|
||||
return Response{Receipt: b.auditReceipt(req, agentcommand.StateRejected, "", "cancelled at confirm card", start)}
|
||||
}
|
||||
resp := b.Dispatch(context.Background(), req)
|
||||
resp := b.Dispatch(ctx, req)
|
||||
if b.cards != nil {
|
||||
if resp.Receipt.State == agentcommand.StateCommitted {
|
||||
_, _ = b.cards.SetStatus(owner, cardID, core.CardResolved, "ok", "confirmed")
|
||||
_, _ = b.setConfirmCardStatus(owner, cardID, core.CardResolved, "ok", "confirmed")
|
||||
} else {
|
||||
_, _ = b.cards.SetStatus(owner, cardID, core.CardFailed, "danger", resp.Receipt.Reason)
|
||||
_, _ = b.setConfirmCardStatus(owner, cardID, core.CardFailed, "danger", resp.Receipt.Reason)
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func accountActor(req Request) string {
|
||||
// Plugin and delegated commands use Actor for an audit label such as a
|
||||
// plugin ID; Owner is the effective account whose state controls effects.
|
||||
if req.Owner != "" {
|
||||
return req.Owner
|
||||
}
|
||||
return req.Actor
|
||||
}
|
||||
|
||||
func (b *Bus) checkEffectiveAccount(ctx context.Context, actor string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.accountGuard == nil {
|
||||
return nil
|
||||
}
|
||||
return b.accountGuard(ctx, actor)
|
||||
}
|
||||
|
||||
func (b *Bus) accountRejected(commandID string, start time.Time) Response {
|
||||
return Response{Receipt: agentcommand.ExecReceipt{
|
||||
CommandID: commandID, State: agentcommand.StateRejected,
|
||||
Reason: "effective account unavailable", Time: start.UTC(),
|
||||
}}
|
||||
}
|
||||
|
||||
func (b *Bus) auditReceipt(req Request, state agentcommand.ResultState, op, reason string, start time.Time) agentcommand.ExecReceipt {
|
||||
env := agentcommand.AuditEnvelope{
|
||||
Time: start.UTC(), Actor: req.Actor, CommandID: req.ID, Entrance: agentcommand.CanonicalEntrance(req.Entrance),
|
||||
@@ -275,7 +480,20 @@ func (b *Bus) auditReceipt(req Request, state agentcommand.ResultState, op, reas
|
||||
if _, src, err := b.reg.Resolve(req.ID); err == nil {
|
||||
env.Source = src
|
||||
}
|
||||
if err := b.audit.AppendAudit(env); err != nil {
|
||||
var err error
|
||||
if b.strictAccountWrites {
|
||||
checked, ok := b.audit.(interface {
|
||||
AppendAuditAccountChecked(string, agentcommand.AuditEnvelope, bool) error
|
||||
})
|
||||
if !ok {
|
||||
err = fmt.Errorf("cmdbus: account-checked audit store required")
|
||||
} else {
|
||||
err = checked.AppendAuditAccountChecked(accountActor(req), env, b.allowImplicitLocal)
|
||||
}
|
||||
} else {
|
||||
err = b.audit.AppendAudit(req.Owner, env)
|
||||
}
|
||||
if err != nil {
|
||||
// 审计不可用:结果标记未知,不臆断成功(四态语义)。
|
||||
return agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateUnknown, Reason: "audit unavailable: " + err.Error(), Time: b.now().UTC()}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ type memAudit struct {
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (m *memAudit) AppendAudit(env agentcommand.AuditEnvelope) error {
|
||||
func (m *memAudit) AppendAudit(owner string, env agentcommand.AuditEnvelope) error {
|
||||
if m.fail {
|
||||
return errors.New("audit down")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package cmdbus
|
||||
|
||||
// EXT-14:Bus.CommandTools 只投影带 ExposeAsTool 的已启用命令;risk 缺省 low、
|
||||
// description 缺省取 title;未暴露/未启用的命令不出现。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
)
|
||||
|
||||
func newToolBus(t *testing.T) *Bus {
|
||||
t.Helper()
|
||||
return New(nopAudit{}, nil, func() time.Time { return time.Now().UTC() })
|
||||
}
|
||||
|
||||
func TestCommandToolsFiltering(t *testing.T) {
|
||||
b := newToolBus(t)
|
||||
specs := []json.RawMessage{
|
||||
[]byte(`{"id":"rdm.trace","title":"追溯查询","description":"查网","paramsSchema":{"type":"object"},"exposeAsTool":{"risk":"medium"}}`),
|
||||
[]byte(`{"id":"rdm.baseline","title":"基线视图","exposeAsTool":{}}`),
|
||||
[]byte(`{"id":"rdm.hidden","title":"未暴露","description":"不出现"}`),
|
||||
}
|
||||
if err := b.RegisterPluginCommands(specs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tools := b.CommandTools()
|
||||
byID := map[string]CommandTool{}
|
||||
for _, ct := range tools {
|
||||
byID[ct.ID] = ct
|
||||
}
|
||||
if len(byID) != 2 {
|
||||
t.Fatalf("projected = %+v, want 2(rdm.trace + rdm.baseline)", tools)
|
||||
}
|
||||
if ct := byID["rdm.trace"]; ct.Risk != "medium" || ct.Description != "查网" || string(ct.Params) != `{"type":"object"}` {
|
||||
t.Fatalf("rdm.trace = %+v", ct)
|
||||
}
|
||||
if ct := byID["rdm.baseline"]; ct.Risk != "low" || ct.Description != "基线视图" {
|
||||
t.Fatalf("rdm.baseline = %+v(risk 缺省 low、description 缺省 title)", ct)
|
||||
}
|
||||
if _, ok := byID["rdm.hidden"]; ok {
|
||||
t.Fatal("未声明 exposeAsTool 的命令不得投影")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandToolDispatchEnvelope(t *testing.T) {
|
||||
b := newToolBus(t)
|
||||
// 注册一个带处理器的暴露命令,经 Dispatch 分发验证完整回执链路。
|
||||
var spec agentcommand.CommandSpec
|
||||
if err := json.Unmarshal([]byte(`{"id":"rdm.echo","title":"回声","exposeAsTool":{"risk":"low"}}`), &spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.RegisterPluginCommand(spec, func(ctx context.Context, req Request) (any, error) {
|
||||
return map[string]any{"echo": req.Params["v"], "actor": req.Actor, "entrance": req.Entrance}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := b.Dispatch(context.Background(), Request{ID: "rdm.echo", Params: map[string]any{"v": 1},
|
||||
Entrance: "agent", Actor: "bob", Owner: "bob", ConversationID: "cnv-1"})
|
||||
if resp.Receipt.State != "committed" {
|
||||
t.Fatalf("receipt = %+v (%s)", resp.Receipt, resp.Receipt.Reason)
|
||||
}
|
||||
m, _ := resp.Payload.(map[string]any)
|
||||
if m["entrance"] != "agent" || m["actor"] != "bob" {
|
||||
t.Fatalf("payload = %+v(Entrance=agent 原样入审计)", m)
|
||||
}
|
||||
}
|
||||
|
||||
type nopAudit struct{}
|
||||
|
||||
func (nopAudit) AppendAudit(owner string, env agentcommand.AuditEnvelope) error { return nil }
|
||||
|
||||
// U3:owner 级注入闸——gate 拒绝的命令不投影(禁用即不注入能力);fail-closed
|
||||
// (owner 为空且 gate 判否时不投影)。
|
||||
func TestCommandToolsForGate(t *testing.T) {
|
||||
bus := New(nil, nil, func() time.Time { return time.Now().UTC() })
|
||||
if err := bus.RegisterPluginCommands([]json.RawMessage{
|
||||
[]byte(`{"id":"generative-ui.present","title":"生成页面","description":"d","paramsSchema":{"type":"object"},"exposeAsTool":{"risk":"low"}}`),
|
||||
[]byte(`{"id":"other.echo","title":"回声","description":"e","paramsSchema":{"type":"object"},"exposeAsTool":{"risk":"low"}}`),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bus.SetToolGate(func(owner, id string) bool {
|
||||
return !strings.HasPrefix(id, "generative-ui.")
|
||||
})
|
||||
on := bus.CommandToolsFor("alice")
|
||||
for _, ct := range on {
|
||||
if strings.HasPrefix(ct.ID, "generative-ui.") {
|
||||
t.Fatalf("gated command leaked for enabled owner: %+v", ct)
|
||||
}
|
||||
}
|
||||
if len(on) != 1 {
|
||||
t.Fatalf("expected only other.echo, got %+v", on)
|
||||
}
|
||||
if n := len(bus.CommandToolsFor("")); n != 1 {
|
||||
t.Fatalf("empty owner must be fail-closed on gated commands, got %d", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Package cmdbuf 意图路由(G2-2:Intent 声明面与自然语言入口)。
|
||||
// 插件经 extension.Registry.AddIntent 声明「短语 → 命令」映射;宿主把声明汇入
|
||||
// 本表。匹配口径:文本归一(去空白/小写)后做包含匹配,最长短语优先——
|
||||
// 无模型参与的确定性第一档;路由终点仍是命令总线(同审计信封与 HITL 收口)。
|
||||
package cmdbus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Intent 一条意图声明:短语命中后分发到 CommandID(参数模板随声明携带)。
|
||||
type Intent struct {
|
||||
ID string `json:"id"` // 形如 <domain>.<intent>(如 rdm.write-proposal)
|
||||
Phrases []string `json:"phrases"` // 触发短语(归一后包含匹配)
|
||||
Command string `json:"command"` // 目标命令 id(须已注册)
|
||||
Params map[string]any `json:"params,omitempty"` // 参数模板(可被调用方覆盖)
|
||||
Description string `json:"description,omitempty"`
|
||||
Plugin string `json:"plugin"` // 声明方(审计归因)
|
||||
}
|
||||
|
||||
// IntentStore 意图声明表(进程内;插件注册期写入,运行期只读)。
|
||||
type IntentStore struct {
|
||||
mu sync.RWMutex
|
||||
byID map[string]Intent
|
||||
phrases []string // 归一短语(排序后长词优先匹配)
|
||||
}
|
||||
|
||||
// NewIntentStore 建表。
|
||||
func NewIntentStore() *IntentStore {
|
||||
return &IntentStore{byID: map[string]Intent{}}
|
||||
}
|
||||
|
||||
// normalize 意图匹配归一:小写、压缩空白。
|
||||
func normalize(s string) string {
|
||||
return strings.Join(strings.Fields(strings.ToLower(s)), " ")
|
||||
}
|
||||
|
||||
// Declare 登记意图(同 id 覆盖;短语与命令必填——fail-closed)。
|
||||
func (s *IntentStore) Declare(in Intent) error {
|
||||
if in.ID == "" || in.Command == "" || len(in.Phrases) == 0 {
|
||||
return fmt.Errorf("cmdbus: intent %q 声明不完整(id/phrases/command 必填)", in.ID)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.byID[in.ID] = in
|
||||
s.rebuild()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *IntentStore) rebuild() {
|
||||
s.phrases = s.phrases[:0]
|
||||
for _, in := range s.byID {
|
||||
for _, p := range in.Phrases {
|
||||
s.phrases = append(s.phrases, normalize(p))
|
||||
}
|
||||
}
|
||||
// 长短语优先:更具体的意图先命中。
|
||||
sort.Slice(s.phrases, func(i, j int) bool { return len(s.phrases[i]) > len(s.phrases[j]) })
|
||||
}
|
||||
|
||||
// Match 自然语言匹配:返回命中的意图(ok=false=未命中)。
|
||||
func (s *IntentStore) Match(text string) (Intent, bool) {
|
||||
n := normalize(text)
|
||||
if n == "" {
|
||||
return Intent{}, false
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, ph := range s.phrases {
|
||||
if ph != "" && strings.Contains(n, ph) {
|
||||
for _, in := range s.byID {
|
||||
for _, p := range in.Phrases {
|
||||
if normalize(p) == ph {
|
||||
return in, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Intent{}, false
|
||||
}
|
||||
|
||||
// List 全部意图声明(清单面)。
|
||||
func (s *IntentStore) List() []Intent {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]Intent, 0, len(s.byID))
|
||||
for _, in := range s.byID {
|
||||
out = append(out, in)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package cmdbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
agentcommand "agent-command"
|
||||
)
|
||||
|
||||
// EXT-10② 红测:插件命令规格自带 slash 别名 → 四入口同源(slash 命中同一命令
|
||||
// id,经总线分发同审计信封)。
|
||||
func TestPluginSlashAliasBindsAndDispatches(t *testing.T) {
|
||||
bus := New(nopAudit{}, nil, nil)
|
||||
called := ""
|
||||
if err := bus.RegisterPluginCommand(spec(t, `{
|
||||
"id": "rdm.ping", "title": "自检", "slash": "/rdm.ping"
|
||||
}`), func(ctx context.Context, req Request) (any, error) {
|
||||
called = req.ID + "|" + req.Entrance
|
||||
return map[string]any{"ok": true}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, ok := bus.ResolveSlash("/rdm.ping")
|
||||
if !ok || id != "rdm.ping" {
|
||||
t.Fatalf("ResolveSlash = %q ok=%v(插件别名未绑定)", id, ok)
|
||||
}
|
||||
// 大小写与首尾空白归一。
|
||||
if id, ok := bus.ResolveSlash(" /RDM.PING "); !ok || id != "rdm.ping" {
|
||||
t.Fatalf("归一解析失败: %q %v", id, ok)
|
||||
}
|
||||
resp := bus.Dispatch(context.Background(), Request{ID: id, Entrance: "slash", Owner: "alice"})
|
||||
if resp.Receipt.State != "committed" {
|
||||
t.Fatalf("slash 分发 = %+v", resp.Receipt)
|
||||
}
|
||||
if called != "rdm.ping|slash" {
|
||||
t.Fatalf("处理器见到 %q(须同命令 id)", called)
|
||||
}
|
||||
// 同一命令按钮入口分发——同源同信封。
|
||||
if resp2 := bus.Dispatch(context.Background(), Request{ID: "rdm.ping", Entrance: "button", Owner: "alice"}); resp2.Receipt.State != "committed" {
|
||||
t.Fatalf("button 分发 = %+v", resp2.Receipt)
|
||||
}
|
||||
}
|
||||
|
||||
// G2-2 红测:意图声明→归一包含匹配(最长短语优先)→路由到命令(chat 入口)。
|
||||
func TestIntentDeclareMatchRoute(t *testing.T) {
|
||||
bus := New(nopAudit{}, nil, nil)
|
||||
_ = bus.RegisterPluginCommand(spec(t, `{
|
||||
"id": "rdm.draft.compose", "title": "起草"
|
||||
}`), func(ctx context.Context, req Request) (any, error) {
|
||||
return map[string]any{"sectionId": req.Params["sectionId"]}, nil
|
||||
})
|
||||
if err := bus.Intents().Declare(Intent{
|
||||
ID: "rdm.write-proposal", Phrases: []string{"帮我写项目建议书", "写项目建议书"},
|
||||
Command: "rdm.draft.compose", Params: map[string]any{"sectionId": "sec-1"},
|
||||
Plugin: "rdm",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 未命中。
|
||||
if _, ok := bus.Intents().Match("今天天气如何"); ok {
|
||||
t.Fatal("无关文本不得命中")
|
||||
}
|
||||
// 命中(含大小写/空白归一)。
|
||||
in, ok := bus.Intents().Match("请 帮我写项目建议书,尽快")
|
||||
if !ok || in.ID != "rdm.write-proposal" {
|
||||
t.Fatalf("匹配 = %+v ok=%v", in, ok)
|
||||
}
|
||||
// 路由:匹配到的命令经总线 chat 入口分发成功。
|
||||
resp := bus.Dispatch(context.Background(), Request{ID: in.Command, Params: in.Params, Entrance: "chat", Owner: "alice"})
|
||||
if resp.Receipt.State != "committed" {
|
||||
t.Fatalf("路由分发 = %+v", resp.Receipt)
|
||||
}
|
||||
// 声明校验:缺短语拒绝。
|
||||
if err := bus.Intents().Declare(Intent{ID: "x", Command: "y"}); err == nil {
|
||||
t.Fatal("不完整声明须拒绝")
|
||||
}
|
||||
// 最长短语优先:更具体的意图先命中。
|
||||
if err := bus.Intents().Declare(Intent{
|
||||
ID: "rdm.write-competitive", Phrases: []string{"写竞品分析"},
|
||||
Command: "rdm.draft.compose", Plugin: "rdm",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if in, _ := bus.Intents().Match("帮我写项目建议书"); in.ID != "rdm.write-proposal" {
|
||||
t.Fatalf("长短语意图应先命中: %+v", in)
|
||||
}
|
||||
}
|
||||
|
||||
func spec(t *testing.T, raw string) agentcommand.CommandSpec {
|
||||
t.Helper()
|
||||
var s agentcommand.CommandSpec
|
||||
if err := json.Unmarshal([]byte(raw), &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
+250
-23
@@ -11,6 +11,7 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -26,6 +27,26 @@ type Config struct {
|
||||
Instance string `json:"instance"`
|
||||
InsecureListen bool `json:"insecureListen"`
|
||||
AllowedHostsRaw string `json:"allowedHosts"`
|
||||
// DisableExecTools 启动级硬关闭执行原语(用户设置 runtime.exec 无法覆盖)。
|
||||
DisableExecTools bool `json:"disableExecTools"`
|
||||
// ExecBackend 执行后端(EXT-16):direct(空 = 现状直启)| sandbox(bwrap
|
||||
// 隔离档)。DisableExecTools 优先级高于后端选择。
|
||||
ExecBackend string `json:"execBackend"`
|
||||
// ExecDepsDir sandbox 档只读依赖层目录(空 = 不挂载)。
|
||||
ExecDepsDir string `json:"execDepsDir"`
|
||||
// ExecWorkspace sandbox 档工作区模式:空=直写;overlay=冻结基底+upper。
|
||||
ExecWorkspace string `json:"execWorkspace"`
|
||||
// Tier 部署档(协作存储方案 KTD1):空=auto(DSN→PG,否则 SQLite);
|
||||
// local=强制 SQLite(不允许 DSN);team=强制 PG(缺 DSN/未开认证拒绝启动,
|
||||
// PG 故障拒绝就绪,绝不回落 SQLite)。
|
||||
Tier string `json:"tier"`
|
||||
// PG 连接预算(KTD12):零值走默认(16/8/300s/不限/不限);负值非法。
|
||||
// PGQueryTimeoutSec>0 时以 statement_timeout 附着到 PG DSN。
|
||||
PGMaxOpen int `json:"pgMaxOpen"`
|
||||
PGMaxIdle int `json:"pgMaxIdle"`
|
||||
PGConnMaxIdleSec int `json:"pgConnMaxIdleSec"`
|
||||
PGConnMaxLifetimeSec int `json:"pgConnMaxLifetimeSec"`
|
||||
PGQueryTimeoutSec int `json:"pgQueryTimeoutSec"`
|
||||
}
|
||||
|
||||
var defaults = Config{
|
||||
@@ -37,16 +58,26 @@ var defaults = Config{
|
||||
|
||||
// envKey 字段 → 环境变量名(AGENT_SYSTEM_ 前缀)。
|
||||
var envKey = map[string]string{
|
||||
"addr": "AGENT_SYSTEM_ADDR",
|
||||
"data": "AGENT_SYSTEM_DATA",
|
||||
"plugins": "AGENT_SYSTEM_PLUGINS",
|
||||
"dsn": "AGENT_SYSTEM_DSN",
|
||||
"artifacts": "AGENT_SYSTEM_ARTIFACTS",
|
||||
"auth": "AGENT_SYSTEM_AUTH",
|
||||
"mock": "AGENT_SYSTEM_MOCK",
|
||||
"instance": "AGENT_SYSTEM_INSTANCE",
|
||||
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
|
||||
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
|
||||
"addr": "AGENT_SYSTEM_ADDR",
|
||||
"data": "AGENT_SYSTEM_DATA",
|
||||
"plugins": "AGENT_SYSTEM_PLUGINS",
|
||||
"dsn": "AGENT_SYSTEM_DSN",
|
||||
"artifacts": "AGENT_SYSTEM_ARTIFACTS",
|
||||
"auth": "AGENT_SYSTEM_AUTH",
|
||||
"mock": "AGENT_SYSTEM_MOCK",
|
||||
"instance": "AGENT_SYSTEM_INSTANCE",
|
||||
"execBackend": "AGENT_SYSTEM_EXEC_BACKEND",
|
||||
"execDepsDir": "AGENT_SYSTEM_EXEC_DEPS_DIR",
|
||||
"execWorkspace": "AGENT_SYSTEM_EXEC_WORKSPACE",
|
||||
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
|
||||
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
|
||||
"disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS",
|
||||
"tier": "AGENT_SYSTEM_TIER",
|
||||
"pgMaxOpen": "AGENT_SYSTEM_PG_MAX_OPEN",
|
||||
"pgMaxIdle": "AGENT_SYSTEM_PG_MAX_IDLE",
|
||||
"pgConnMaxIdleSec": "AGENT_SYSTEM_PG_CONN_MAX_IDLE_SEC",
|
||||
"pgConnMaxLifetimeSec": "AGENT_SYSTEM_PG_CONN_MAX_LIFETIME_SEC",
|
||||
"pgQueryTimeoutSec": "AGENT_SYSTEM_PG_QUERY_TIMEOUT_SEC",
|
||||
}
|
||||
|
||||
// Source 配置来源层级(数值越大优先级越高)。
|
||||
@@ -96,11 +127,7 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet
|
||||
}
|
||||
// 文件层按"键存在"覆盖(区分未出现与显式 false/空串;空值有效性由字段语义判定)。
|
||||
for f := range present {
|
||||
if isBoolField(f) {
|
||||
setBool(&cfg, f, boolField(fc, f))
|
||||
} else {
|
||||
setString(&cfg, f, stringField(fc, f))
|
||||
}
|
||||
setField(&cfg, f, fc)
|
||||
src[f] = SrcFile
|
||||
}
|
||||
}
|
||||
@@ -113,6 +140,12 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet
|
||||
return Resolved{}, err
|
||||
}
|
||||
setBool(&cfg, f, b)
|
||||
} else if isIntField(f) {
|
||||
n, err := parseInt(key, v)
|
||||
if err != nil {
|
||||
return Resolved{}, err
|
||||
}
|
||||
setInt(&cfg, f, n)
|
||||
} else {
|
||||
setString(&cfg, f, v)
|
||||
}
|
||||
@@ -122,15 +155,22 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet
|
||||
// CLI 层:仅显式传入。
|
||||
for f := range cliSet {
|
||||
src[f] = SrcCLI
|
||||
if isBoolField(f) {
|
||||
setBool(&cfg, f, boolField(cli, f))
|
||||
} else {
|
||||
setString(&cfg, f, stringField(cli, f))
|
||||
}
|
||||
setField(&cfg, f, cli)
|
||||
}
|
||||
return Resolved{Config: cfg, sources: src}, nil
|
||||
}
|
||||
|
||||
// setField 从同类型快照覆盖单个字段(文件/CLI 层复用;字段类型由字段名决定)。
|
||||
func setField(dst *Config, f string, src Config) {
|
||||
if isBoolField(f) {
|
||||
setBool(dst, f, boolField(src, f))
|
||||
} else if isIntField(f) {
|
||||
setInt(dst, f, intField(src, f))
|
||||
} else {
|
||||
setString(dst, f, stringField(src, f))
|
||||
}
|
||||
}
|
||||
|
||||
func loadFile(path string) (Config, map[string]bool, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -165,6 +205,26 @@ func loadFile(path string) (Config, map[string]bool, error) {
|
||||
dst = &c.InsecureListen
|
||||
case "allowedHosts":
|
||||
dst = &c.AllowedHostsRaw
|
||||
case "disableExecTools":
|
||||
dst = &c.DisableExecTools
|
||||
case "execBackend":
|
||||
dst = &c.ExecBackend
|
||||
case "execDepsDir":
|
||||
dst = &c.ExecDepsDir
|
||||
case "execWorkspace":
|
||||
dst = &c.ExecWorkspace
|
||||
case "tier":
|
||||
dst = &c.Tier
|
||||
case "pgMaxOpen":
|
||||
dst = &c.PGMaxOpen
|
||||
case "pgMaxIdle":
|
||||
dst = &c.PGMaxIdle
|
||||
case "pgConnMaxIdleSec":
|
||||
dst = &c.PGConnMaxIdleSec
|
||||
case "pgConnMaxLifetimeSec":
|
||||
dst = &c.PGConnMaxLifetimeSec
|
||||
case "pgQueryTimeoutSec":
|
||||
dst = &c.PGQueryTimeoutSec
|
||||
default:
|
||||
return Config{}, nil, fmt.Errorf("config file %s: unknown key %q", path, k)
|
||||
}
|
||||
@@ -194,18 +254,67 @@ func stringField(c Config, f string) string {
|
||||
return c.Instance
|
||||
case "allowedHosts":
|
||||
return c.AllowedHostsRaw
|
||||
case "execBackend":
|
||||
return c.ExecBackend
|
||||
case "execDepsDir":
|
||||
return c.ExecDepsDir
|
||||
case "execWorkspace":
|
||||
return c.ExecWorkspace
|
||||
case "tier":
|
||||
return c.Tier
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isBoolField(f string) bool {
|
||||
return f == "mock" || f == "insecureListen"
|
||||
return f == "mock" || f == "insecureListen" || f == "disableExecTools"
|
||||
}
|
||||
|
||||
func isIntField(f string) bool {
|
||||
switch f {
|
||||
case "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func intField(c Config, f string) int {
|
||||
switch f {
|
||||
case "pgMaxOpen":
|
||||
return c.PGMaxOpen
|
||||
case "pgMaxIdle":
|
||||
return c.PGMaxIdle
|
||||
case "pgConnMaxIdleSec":
|
||||
return c.PGConnMaxIdleSec
|
||||
case "pgConnMaxLifetimeSec":
|
||||
return c.PGConnMaxLifetimeSec
|
||||
case "pgQueryTimeoutSec":
|
||||
return c.PGQueryTimeoutSec
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func setInt(c *Config, f string, v int) {
|
||||
switch f {
|
||||
case "pgMaxOpen":
|
||||
c.PGMaxOpen = v
|
||||
case "pgMaxIdle":
|
||||
c.PGMaxIdle = v
|
||||
case "pgConnMaxIdleSec":
|
||||
c.PGConnMaxIdleSec = v
|
||||
case "pgConnMaxLifetimeSec":
|
||||
c.PGConnMaxLifetimeSec = v
|
||||
case "pgQueryTimeoutSec":
|
||||
c.PGQueryTimeoutSec = v
|
||||
}
|
||||
}
|
||||
|
||||
func boolField(c Config, f string) bool {
|
||||
switch f {
|
||||
case "insecureListen":
|
||||
return c.InsecureListen
|
||||
case "disableExecTools":
|
||||
return c.DisableExecTools
|
||||
default:
|
||||
return c.Mock
|
||||
}
|
||||
@@ -215,6 +324,8 @@ func setBool(c *Config, f string, v bool) {
|
||||
switch f {
|
||||
case "insecureListen":
|
||||
c.InsecureListen = v
|
||||
case "disableExecTools":
|
||||
c.DisableExecTools = v
|
||||
default:
|
||||
c.Mock = v
|
||||
}
|
||||
@@ -238,6 +349,14 @@ func setString(c *Config, f, v string) {
|
||||
c.Instance = v
|
||||
case "allowedHosts":
|
||||
c.AllowedHostsRaw = v
|
||||
case "execBackend":
|
||||
c.ExecBackend = v
|
||||
case "execDepsDir":
|
||||
c.ExecDepsDir = v
|
||||
case "execWorkspace":
|
||||
c.ExecWorkspace = v
|
||||
case "tier":
|
||||
c.Tier = v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +370,14 @@ func parseBool(name, v string) (bool, error) {
|
||||
return false, fmt.Errorf("env %s: invalid bool %q", name, v)
|
||||
}
|
||||
|
||||
func parseInt(name, v string) (int, error) {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("env %s: invalid int %q", name, v)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Explain 只读诊断:逐字段打印解析值与来源层级,DSN 脱敏。
|
||||
func (r Resolved) Explain(w io.Writer, file string) {
|
||||
if file != "" {
|
||||
@@ -259,16 +386,32 @@ func (r Resolved) Explain(w io.Writer, file string) {
|
||||
fmt.Fprintln(w, "config file: (none)")
|
||||
}
|
||||
fmt.Fprintln(w, "field value source")
|
||||
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts"} {
|
||||
v := stringField(r.Config, f)
|
||||
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools", "execBackend", "tier", "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec"} {
|
||||
var v string
|
||||
if isBoolField(f) {
|
||||
v = fmt.Sprintf("%t", boolField(r.Config, f))
|
||||
} else if isIntField(f) {
|
||||
n := intField(r.Config, f)
|
||||
if d := intDefault(f); n == 0 && d != 0 {
|
||||
v = fmt.Sprintf("%d (默认)", d)
|
||||
} else {
|
||||
v = fmt.Sprintf("%d", n)
|
||||
}
|
||||
} else {
|
||||
v = stringField(r.Config, f)
|
||||
}
|
||||
if f == "dsn" {
|
||||
v = redact(v)
|
||||
}
|
||||
fmt.Fprintf(w, "%-16s %-56s %s\n", f, v, r.sources[f])
|
||||
}
|
||||
if msgs := r.TierProblems(); len(msgs) > 0 {
|
||||
for _, msg := range msgs {
|
||||
fmt.Fprintf(w, "tier guard: 拒绝启动(%s)\n", msg)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(w, "tier guard: 放行")
|
||||
}
|
||||
if msg := r.ListenRefusal(); msg != "" {
|
||||
fmt.Fprintf(w, "listen guard: 拒绝启动(%s)\n", msg)
|
||||
} else if r.InsecureListen && !IsLoopbackListen(r.Addr) && !r.AuthEnabled() {
|
||||
@@ -283,6 +426,90 @@ func (r Resolved) AuthEnabled() bool {
|
||||
return r.AuthMode == "on" || (r.AuthMode != "off" && r.StorageDSN != "")
|
||||
}
|
||||
|
||||
// intDefault 返回 int 字段的默认值(0 表示零值合法、无默认替换)。
|
||||
func intDefault(f string) int {
|
||||
switch f {
|
||||
case "pgMaxOpen":
|
||||
return DefaultPGMaxOpen
|
||||
case "pgMaxIdle":
|
||||
return DefaultPGMaxIdle
|
||||
case "pgConnMaxIdleSec":
|
||||
return DefaultPGConnMaxIdleSec
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 连接预算默认值(KTD12;与 store 层默认保持一致,两处同时修改时以测试对齐)。
|
||||
const (
|
||||
DefaultPGMaxOpen = 16
|
||||
DefaultPGMaxIdle = 8
|
||||
DefaultPGConnMaxIdleSec = 300
|
||||
)
|
||||
|
||||
// TierProblems 部署档语义校验:返回阻止启动的问题列表(空 = 放行)。
|
||||
// team 档 fail-closed:缺 DSN、未开认证直接拒绝;local 档不允许 DSN(KTD1)。
|
||||
// 执行隔离在 team 档是启动警告而非拒绝(本增量先可见、U3/U5 再硬化)。
|
||||
func (r Resolved) TierProblems() []string {
|
||||
var msgs []string
|
||||
switch r.Tier {
|
||||
case "", "local", "team":
|
||||
default:
|
||||
msgs = append(msgs, fmt.Sprintf("未知部署档 %q(local | team)", r.Tier))
|
||||
}
|
||||
if r.Tier == "team" {
|
||||
if r.StorageDSN == "" {
|
||||
msgs = append(msgs, "team 档需要 PostgreSQL DSN(dsn / AGENT_SYSTEM_DSN);不会回落 SQLite")
|
||||
}
|
||||
if !r.AuthEnabled() {
|
||||
msgs = append(msgs, "team 档必须开启认证(auth=on)")
|
||||
}
|
||||
}
|
||||
if r.Tier == "local" && r.StorageDSN != "" {
|
||||
msgs = append(msgs, "local 档不使用 PostgreSQL DSN(改用 team 档,或去掉 dsn)")
|
||||
}
|
||||
if v := r.PGMaxOpen; v < 0 {
|
||||
msgs = append(msgs, fmt.Sprintf("pgMaxOpen 不能为负(当前 %d)", v))
|
||||
}
|
||||
if v := r.PGMaxIdle; v < 0 {
|
||||
msgs = append(msgs, fmt.Sprintf("pgMaxIdle 不能为负(当前 %d)", v))
|
||||
}
|
||||
if v := r.PGConnMaxIdleSec; v < 0 {
|
||||
msgs = append(msgs, fmt.Sprintf("pgConnMaxIdleSec 不能为负(当前 %d)", v))
|
||||
}
|
||||
if v := r.PGConnMaxLifetimeSec; v < 0 {
|
||||
msgs = append(msgs, fmt.Sprintf("pgConnMaxLifetimeSec 不能为负(当前 %d)", v))
|
||||
}
|
||||
if v := r.PGQueryTimeoutSec; v < 0 {
|
||||
msgs = append(msgs, fmt.Sprintf("pgQueryTimeoutSec 不能为负(当前 %d)", v))
|
||||
}
|
||||
if idle, open := r.EffectivePGMaxIdle(), r.EffectivePGMaxOpen(); idle > open {
|
||||
msgs = append(msgs, fmt.Sprintf("pgMaxIdle(%d) 大于 pgMaxOpen(%d)", idle, open))
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
// EffectivePGMaxOpen/Idle/ConnMaxIdleSec 返回连接预算有效值(零值落默认,KTD12)。
|
||||
func (r Resolved) EffectivePGMaxOpen() int {
|
||||
if r.PGMaxOpen > 0 {
|
||||
return r.PGMaxOpen
|
||||
}
|
||||
return DefaultPGMaxOpen
|
||||
}
|
||||
|
||||
func (r Resolved) EffectivePGMaxIdle() int {
|
||||
if r.PGMaxIdle > 0 {
|
||||
return r.PGMaxIdle
|
||||
}
|
||||
return DefaultPGMaxIdle
|
||||
}
|
||||
|
||||
func (r Resolved) EffectivePGConnMaxIdleSec() int {
|
||||
if r.PGConnMaxIdleSec > 0 {
|
||||
return r.PGConnMaxIdleSec
|
||||
}
|
||||
return DefaultPGConnMaxIdleSec
|
||||
}
|
||||
|
||||
// AllowedHosts 把逗号分隔的主机名拆开,忽略空段。
|
||||
func (r Resolved) AllowedHosts() []string {
|
||||
var out []string
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -123,6 +124,38 @@ func TestEnvBool(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// disableExecTools:默认 false;文件、环境、CLI 三层均可打开,CLI 优先。
|
||||
func TestDisableExecToolsLayers(t *testing.T) {
|
||||
r, err := Load("", noEnv, Config{}, nil)
|
||||
if err != nil || r.DisableExecTools {
|
||||
t.Fatalf("default must be false: %v %+v", err, r.Config)
|
||||
}
|
||||
f := fileWith(t, `{"disableExecTools":true}`)
|
||||
r, err = Load(f, noEnv, Config{}, nil)
|
||||
if err != nil || !r.DisableExecTools || r.SourceOf("disableExecTools") != SrcFile {
|
||||
t.Fatalf("file layer: %v %+v src=%v", err, r.Config, r.SourceOf("disableExecTools"))
|
||||
}
|
||||
env := func(k string) (string, bool) {
|
||||
if k == "AGENT_SYSTEM_DISABLE_EXEC_TOOLS" {
|
||||
return "1", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
r, err = Load("", env, Config{}, nil)
|
||||
if err != nil || !r.DisableExecTools || r.SourceOf("disableExecTools") != SrcEnv {
|
||||
t.Fatalf("env layer: %v %+v", err, r.Config)
|
||||
}
|
||||
r, err = Load("", env, Config{DisableExecTools: false}, map[string]bool{"disableExecTools": true})
|
||||
if err != nil || r.DisableExecTools || r.SourceOf("disableExecTools") != SrcCLI {
|
||||
t.Fatalf("cli must win: %v %+v", err, r.Config)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
r.Explain(&out, "")
|
||||
if !bytes.Contains(out.Bytes(), []byte("disableExecTools")) {
|
||||
t.Fatalf("explain must list disableExecTools:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// explain:来源标注 + DSN 脱敏;只读(不落任何盘外文件)。
|
||||
func TestExplainRedactsDSN(t *testing.T) {
|
||||
env := func(k string) (string, bool) {
|
||||
@@ -188,3 +221,135 @@ func TestListenGuard(t *testing.T) {
|
||||
t.Fatalf("auth on should pass: %+v %q", authed.Config, authed.ListenRefusal())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 协作存储方案 U2:部署档(KTD1)与连接预算(KTD12)----
|
||||
|
||||
// team 档 fail-closed:缺 DSN、未开认证都拒绝;DSN+auth=on 放行。
|
||||
func TestTierTeamFailClosed(t *testing.T) {
|
||||
r, err := Load("", noEnv, Config{Tier: "team"}, map[string]bool{"tier": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs := r.TierProblems()
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("team without dsn/auth should refuse with 2 problems, got %v", msgs)
|
||||
}
|
||||
joined := msgs[0] + msgs[1]
|
||||
if !strings.Contains(joined, "PostgreSQL DSN") || !strings.Contains(joined, "认证") {
|
||||
t.Fatalf("refusal messages wrong: %v", msgs)
|
||||
}
|
||||
|
||||
// 补上 DSN(auto 认证随 DSN 开启)→ 放行。
|
||||
r, err = Load("", noEnv, Config{Tier: "team", StorageDSN: "postgres://u:p@h/db"}, map[string]bool{"tier": true, "dsn": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msgs = r.TierProblems(); len(msgs) != 0 {
|
||||
t.Fatalf("team with dsn should pass, got %v", msgs)
|
||||
}
|
||||
|
||||
// 显式关认证仍然拒绝。
|
||||
r, err = Load("", noEnv, Config{Tier: "team", StorageDSN: "postgres://u:p@h/db", AuthMode: "off"}, map[string]bool{"tier": true, "dsn": true, "auth": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msgs = r.TierProblems(); len(msgs) != 1 || !strings.Contains(msgs[0], "认证") {
|
||||
t.Fatalf("team with auth=off should refuse, got %v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// local 档不允许 DSN;未知档名拒绝。
|
||||
func TestTierLocalAndUnknown(t *testing.T) {
|
||||
r, err := Load("", noEnv, Config{Tier: "local", StorageDSN: "postgres://u:p@h/db"}, map[string]bool{"tier": true, "dsn": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msgs := r.TierProblems(); len(msgs) != 1 || !strings.Contains(msgs[0], "local 档不使用") {
|
||||
t.Fatalf("local with dsn should refuse, got %v", msgs)
|
||||
}
|
||||
r, err = Load("", noEnv, Config{Tier: "cluster"}, map[string]bool{"tier": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msgs := r.TierProblems(); len(msgs) != 1 || !strings.Contains(msgs[0], "未知部署档") {
|
||||
t.Fatalf("unknown tier should refuse, got %v", msgs)
|
||||
}
|
||||
// auto(空)档维持现状语义:无 DSN 不拒绝。
|
||||
r, err = Load("", noEnv, Config{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msgs := r.TierProblems(); len(msgs) != 0 {
|
||||
t.Fatalf("auto default should pass, got %v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// 连接预算四源解析与环境变量整数校验;MaxIdle>MaxOpen 拒绝。
|
||||
func TestPGPoolConfigLayers(t *testing.T) {
|
||||
env := func(k string) (string, bool) {
|
||||
switch k {
|
||||
case "AGENT_SYSTEM_PG_MAX_OPEN":
|
||||
return "7", true
|
||||
case "AGENT_SYSTEM_PG_MAX_IDLE":
|
||||
return "9", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
f := fileWith(t, `{"pgMaxOpen":5}`)
|
||||
r, err := Load(f, env, Config{PGMaxOpen: 3, PGMaxIdle: 2}, map[string]bool{"pgMaxIdle": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 文件 5 被 env 7 覆盖(CLI 值 3 未显式传入不参与);pgMaxIdle CLI 显式 2 胜。
|
||||
if r.PGMaxOpen != 7 || r.SourceOf("pgMaxOpen") != SrcEnv {
|
||||
t.Fatalf("pgMaxOpen = %d (%v), want env 7", r.PGMaxOpen, r.SourceOf("pgMaxOpen"))
|
||||
}
|
||||
if r.PGMaxIdle != 2 || r.SourceOf("pgMaxIdle") != SrcCLI {
|
||||
t.Fatalf("pgMaxIdle = %d (%v), want CLI 2", r.PGMaxIdle, r.SourceOf("pgMaxIdle"))
|
||||
}
|
||||
if msgs := r.TierProblems(); len(msgs) != 0 {
|
||||
t.Fatalf("2/2 pool valid, got %v", msgs)
|
||||
}
|
||||
// 有效值:零值落默认。
|
||||
r, err = Load("", noEnv, Config{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.EffectivePGMaxOpen() != DefaultPGMaxOpen || r.EffectivePGMaxIdle() != DefaultPGMaxIdle || r.EffectivePGConnMaxIdleSec() != DefaultPGConnMaxIdleSec {
|
||||
t.Fatalf("effective defaults wrong: %+v", r.Config)
|
||||
}
|
||||
// idle > open 拒绝。
|
||||
r, err = Load("", noEnv, Config{PGMaxOpen: 2, PGMaxIdle: 3}, map[string]bool{"pgMaxOpen": true, "pgMaxIdle": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msgs := r.TierProblems(); len(msgs) != 1 || !strings.Contains(msgs[0], "pgMaxIdle(3) 大于 pgMaxOpen(2)") {
|
||||
t.Fatalf("idle>open should refuse, got %v", msgs)
|
||||
}
|
||||
// 非法整数环境变量报错定位到变量。
|
||||
bad := func(k string) (string, bool) {
|
||||
if k == "AGENT_SYSTEM_PG_MAX_OPEN" {
|
||||
return "many", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
if _, err := Load("", bad, Config{}, nil); err == nil || !strings.Contains(err.Error(), "AGENT_SYSTEM_PG_MAX_OPEN") {
|
||||
t.Fatalf("invalid int env should fail with var name, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// explain 输出包含部署档与预算行,且 tier guard 结论可见(脱敏诊断)。
|
||||
func TestExplainTierAndPool(t *testing.T) {
|
||||
r, err := Load("", noEnv, Config{Tier: "team"}, map[string]bool{"tier": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b bytes.Buffer
|
||||
r.Explain(&b, "")
|
||||
out := b.String()
|
||||
for _, want := range []string{"tier", "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec", "tier guard: 拒绝启动"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("explain missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+103
-14
@@ -36,13 +36,23 @@ type CardResolver func(id core.ID) *core.ActivityCard
|
||||
// 对称)。ok=false = 无工件服务/无此卡/内容不可读,部件留占位行。
|
||||
type ArtifactResolver func(id core.ID) (art core.Artifact, content []byte, ok bool)
|
||||
|
||||
// AssembleOptions 是组装的可选行为。零值全部关闭(向后兼容)。
|
||||
type AssembleOptions struct {
|
||||
// ThinkReplay 为真时,历史 assistant 消息的思考部件以 <think> 块原样回传
|
||||
//(交错思考模型如 MiniMax 的官方要求:丢弃思考内容会降低后续轮性能)。
|
||||
ThinkReplay bool
|
||||
// Surfaces 是当前会话最近保存的生成页面(U3/R6):以语义投影并入上下文,
|
||||
// 使下一轮模型能解释并修改已有结构(修改走 generative-ui.present 重提)。
|
||||
Surfaces []core.SurfaceSnapshot
|
||||
}
|
||||
|
||||
// AssembleInput 组装模型输入消息。sys 为提示词库组装出的 system 段(可为多段;
|
||||
// 调用方经 prompts.Service.ResolveSystem 产出),promptObs 为对应可观测条目。
|
||||
// prof.SystemPrompt 非空时作为画像自定义段追加(向后兼容旧画像)。
|
||||
// resolveCard 用于 card 引用部件回填产物卡正文(submit_card;D35:卡片为产物权威
|
||||
// 载体,下一轮模型不失忆),nil 时 card 部件只留占位。resolveArtifact 同理回填
|
||||
// artifact_ref 工件正文(D46 统一;仅 UTF-8 文本,超 limit 截断留痕)。
|
||||
func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history []core.Message, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, sys []agentllm.Message, promptObs []string, resolveCard CardResolver, resolveArtifact ArtifactResolver) ([]agentllm.Message, *Report) {
|
||||
func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history []core.Message, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, sys []agentllm.Message, promptObs []string, resolveCard CardResolver, resolveArtifact ArtifactResolver, opts AssembleOptions) ([]agentllm.Message, *Report) {
|
||||
rep := &Report{}
|
||||
msgs := make([]agentllm.Message, 0, len(sys)+1)
|
||||
msgs = append(msgs, sys...)
|
||||
@@ -51,6 +61,17 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
|
||||
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: prof.SystemPrompt})
|
||||
rep.Items = append(rep.Items, "system(画像自定义段)")
|
||||
}
|
||||
if len(opts.Surfaces) > 0 {
|
||||
var b strings.Builder
|
||||
b.WriteString("[本会话已保存的生成页面(结构摘要;正文以预览区为准)]\n")
|
||||
for _, snap := range opts.Surfaces {
|
||||
b.WriteString(projectSurface(snap))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("用户要求调整页面时,按结构摘要理解现状并用 generative-ui.present 提交修订(带 surfaceId 与 baseRevision)。")
|
||||
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: b.String()})
|
||||
rep.Items = append(rep.Items, fmt.Sprintf("surface(%d)", len(opts.Surfaces)))
|
||||
}
|
||||
|
||||
maxHist := prof.Context.MaxHistoryMessages
|
||||
if maxHist <= 0 {
|
||||
@@ -84,7 +105,7 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
|
||||
continue
|
||||
}
|
||||
if m.Role == core.RoleAssistant {
|
||||
msgs = append(msgs, renderAssistantTurns(m, resolveCard, limitCard, resolveArtifact, rep, &cardsBackfilled, &artsBackfilled)...)
|
||||
msgs = append(msgs, renderAssistantTurns(m, resolveCard, limitCard, resolveArtifact, rep, &cardsBackfilled, &artsBackfilled, opts)...)
|
||||
continue
|
||||
}
|
||||
content := renderMessage(m, resolveCard, limitCard, resolveArtifact, rep, &cardsBackfilled, &artsBackfilled)
|
||||
@@ -102,6 +123,16 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
|
||||
}
|
||||
|
||||
// 当前轮:文本 + 附件解析结果 + 知识检索注入。
|
||||
msgs = append(msgs, BuildCurrentUser(eff, currentUser, attachments, knowledge, rep))
|
||||
|
||||
fillEstimate(msgs, rep)
|
||||
return msgs, rep
|
||||
}
|
||||
|
||||
// BuildCurrentUser 组装当前轮的用户消息(文本部件 + 附件解析全文 + 知识检索)。
|
||||
// 会话转录为权威上下文时,每轮只构建这一条消息,历史由转录承载——
|
||||
// 此处与整段组装共用同一实现,保证两条路径的当前轮语义一致。
|
||||
func BuildCurrentUser(eff core.EffectiveSettings, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, rep *Report) agentllm.Message {
|
||||
var sb strings.Builder
|
||||
for _, p := range currentUser.Parts {
|
||||
if p.Type == core.PartText {
|
||||
@@ -118,23 +149,26 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
|
||||
text := doc.PlainText()
|
||||
if runes := []rune(text); len(runes) > limit {
|
||||
text = string(runes[:limit]) + "…(附件超长截断)"
|
||||
rep.Truncations = append(rep.Truncations, fmt.Sprintf("附件 %s 超 %d 字符截断(原文 %d 字符)", doc.Meta.Filename, limit, len(runes)))
|
||||
if rep != nil {
|
||||
rep.Truncations = append(rep.Truncations, fmt.Sprintf("附件 %s 超 %d 字符截断(原文 %d 字符)", doc.Meta.Filename, limit, len(runes)))
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&sb, "### %s\n%s\n", doc.Meta.Filename, text)
|
||||
}
|
||||
rep.Items = append(rep.Items, fmt.Sprintf("attachments(%d)", len(attachments)))
|
||||
if rep != nil {
|
||||
rep.Items = append(rep.Items, fmt.Sprintf("attachments(%d)", len(attachments)))
|
||||
}
|
||||
}
|
||||
if len(knowledge) > 0 {
|
||||
sb.WriteString("\n\n[相关知识]\n")
|
||||
for i, h := range knowledge {
|
||||
fmt.Fprintf(&sb, "[%d] %s(来源 %s)\n%s\n", i+1, h.Doc.Title, h.Doc.Source, h.Doc.Chunk)
|
||||
}
|
||||
rep.Items = append(rep.Items, fmt.Sprintf("knowledge(%d)", len(knowledge)))
|
||||
if rep != nil {
|
||||
rep.Items = append(rep.Items, fmt.Sprintf("knowledge(%d)", len(knowledge)))
|
||||
}
|
||||
}
|
||||
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleUser, Content: sb.String()})
|
||||
|
||||
fillEstimate(msgs, rep)
|
||||
return msgs, rep
|
||||
return agentllm.Message{Role: agentllm.RoleUser, Content: sb.String()}
|
||||
}
|
||||
|
||||
// maxToolArgRunes 是回放进模型的单次工具参数上限。超限时截断字符串值,保持合法 JSON。
|
||||
@@ -171,17 +205,24 @@ func renderMessage(m core.Message, resolveCard CardResolver, limitCard int, reso
|
||||
// 前置文本与 tool_calls 组成一条 assistant,每个调用紧跟一条 tool 结果。
|
||||
// 没有结果的调用补一条失败占位,避免模型把中断当成成功。孤立的结果直接跳过。
|
||||
// 被守卫降级、且后面不是工具调用的过程文本不回放。
|
||||
func renderAssistantTurns(m core.Message, resolveCard CardResolver, limitCard int, resolveArtifact ArtifactResolver, rep *Report, cardsBackfilled, artsBackfilled *int) []agentllm.Message {
|
||||
func renderAssistantTurns(m core.Message, resolveCard CardResolver, limitCard int, resolveArtifact ArtifactResolver, rep *Report, cardsBackfilled, artsBackfilled *int, opts AssembleOptions) []agentllm.Message {
|
||||
var out []agentllm.Message
|
||||
var text strings.Builder
|
||||
var think strings.Builder
|
||||
var calls []agentllm.ToolCall
|
||||
results := map[string]core.ToolResultPart{}
|
||||
seq := 0
|
||||
|
||||
flush := func() {
|
||||
content := strings.TrimSpace(text.String())
|
||||
thinkText := strings.TrimSpace(think.String())
|
||||
if opts.ThinkReplay && thinkText != "" {
|
||||
// 交错思考模型要求历史 assistant 原样带 <think>;只进请求,不落盘。
|
||||
content = "<think>\n" + thinkText + "\n</think>\n\n" + content
|
||||
}
|
||||
if content == "" && len(calls) == 0 {
|
||||
text.Reset()
|
||||
think.Reset()
|
||||
results = map[string]core.ToolResultPart{}
|
||||
return
|
||||
}
|
||||
@@ -202,12 +243,18 @@ func renderAssistantTurns(m core.Message, resolveCard CardResolver, limitCard in
|
||||
})
|
||||
}
|
||||
text.Reset()
|
||||
think.Reset()
|
||||
calls = nil
|
||||
results = map[string]core.ToolResultPart{}
|
||||
}
|
||||
|
||||
for i, p := range m.Parts {
|
||||
switch p.Type {
|
||||
case core.PartThinking:
|
||||
if strings.TrimSpace(p.Text) != "" {
|
||||
think.WriteString(p.Text)
|
||||
think.WriteString("\n")
|
||||
}
|
||||
case core.PartText:
|
||||
if demotedProcessText(m.Parts, i) {
|
||||
continue
|
||||
@@ -381,19 +428,22 @@ func renderCardPart(cp *core.CardPart, resolveCard CardResolver, limit int, rep
|
||||
// renderArtifactRef 渲染 artifact_ref 部件:有 resolver 且内容为 UTF-8 文本时
|
||||
// 回填正文(与卡同 limit,超限截断留痕);二进制/不可读/无 resolver 留占位行
|
||||
// (可见即信号,不静默丢弃)。
|
||||
// 占位文案不带「[产出工件 …]」方括号形态:那正是 ClaimGate 用来识别伪造调用
|
||||
// 标记的模式——历史里自己产出这种格式,下一轮模型照抄就会被终轮检查误判为
|
||||
// 「正文里出现了工具调用标记」。
|
||||
func renderArtifactRef(a *core.ArtifactRef, resolve ArtifactResolver, limit int, rep *Report, backfilled *int) string {
|
||||
if a == nil {
|
||||
return ""
|
||||
}
|
||||
if resolve == nil {
|
||||
return fmt.Sprintf("[产出工件 %s]\n", a.Name)
|
||||
return fmt.Sprintf("此前已生成文件:%s。\n", a.Name)
|
||||
}
|
||||
art, content, ok := resolve(a.ArtifactID)
|
||||
if !ok {
|
||||
return fmt.Sprintf("[产出工件 %s]\n", a.Name)
|
||||
return fmt.Sprintf("此前已生成文件:%s。\n", a.Name)
|
||||
}
|
||||
if !utf8.Valid(content) {
|
||||
return fmt.Sprintf("[产出工件 %s(二进制内容,正文见工件查看器)]\n", art.Name)
|
||||
return fmt.Sprintf("此前已生成文件:%s(二进制内容,正文见工件查看器)。\n", art.Name)
|
||||
}
|
||||
*backfilled++
|
||||
text := string(content)
|
||||
@@ -401,7 +451,7 @@ func renderArtifactRef(a *core.ArtifactRef, resolve ArtifactResolver, limit int,
|
||||
text = string(runes[:limit]) + "…(工件超长截断)"
|
||||
rep.Truncations = append(rep.Truncations, fmt.Sprintf("工件 %s 超 %d 字符截断", art.Name, limit))
|
||||
}
|
||||
return fmt.Sprintf("[产出工件 %s]\n%s\n", art.Name, text)
|
||||
return fmt.Sprintf("此前已生成文件 %s,内容如下:\n%s\n", art.Name, text)
|
||||
}
|
||||
|
||||
// extractiveSummary 把窗口外历史压成短摘要(不调用模型)。summarizeBeyond 打开时注入。
|
||||
@@ -463,3 +513,42 @@ func InjectMemory(sys []agentllm.Message, entries []core.MemoryEntry) []agentllm
|
||||
out = append(out, agentllm.Message{Role: agentllm.RoleSystem, Content: b.String()})
|
||||
return out
|
||||
}
|
||||
|
||||
// projectSurface 是 ctxasm 侧的结构摘要投影(与 surfacesvc.ProjectSurface 同义;
|
||||
// 独立实现避免 ctxasm→surfacesvc 依赖边)。
|
||||
func projectSurface(snap core.SurfaceSnapshot) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "- 页面 %s(修订 %d)%q", string(snap.Identity.SurfaceID), snap.Revision, snap.Spec.Title)
|
||||
if snap.SemanticText != "" {
|
||||
fmt.Fprintf(&b, ":%s", snap.SemanticText)
|
||||
}
|
||||
b.WriteString("\n 结构:")
|
||||
var walk func(n core.UINode)
|
||||
first := true
|
||||
walk = func(n core.UINode) {
|
||||
label := string(n.Type)
|
||||
if n.Title != "" {
|
||||
label += "(\"" + n.Title + "\")"
|
||||
}
|
||||
if n.ID != "" {
|
||||
label += "#" + n.ID
|
||||
}
|
||||
if len(n.Source) > 0 {
|
||||
label += "[" + strings.Join(n.Source, ",") + "]"
|
||||
}
|
||||
if first {
|
||||
b.WriteString(label)
|
||||
first = false
|
||||
} else {
|
||||
b.WriteString(" > " + label)
|
||||
}
|
||||
for _, c := range n.Children {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
for _, n := range snap.Spec.Nodes {
|
||||
walk(n)
|
||||
}
|
||||
b.WriteString("\n 修订此页:present 带 surfaceId=" + string(snap.Identity.SurfaceID) + " 与 baseRevision=" + fmt.Sprint(snap.Revision) + "。")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestAssembleInputAttachmentLimit(t *testing.T) {
|
||||
Meta: core.DocMeta{Filename: "纪要.docx"},
|
||||
Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}},
|
||||
}
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("请总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil)
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("请总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
|
||||
last := msgs[len(msgs)-1]
|
||||
if !strings.Contains(last.Content, "纪要.docx") {
|
||||
@@ -42,7 +42,7 @@ func TestAssembleInputAttachmentLimitConfigurable(t *testing.T) {
|
||||
Blocks: []core.DocBlock{{Type: core.BlockPara, Text: strings.Repeat("甲", 120)}},
|
||||
}
|
||||
eff := core.EffectiveSettings{"context": map[string]any{"maxAttachmentChars": float64(100)}}
|
||||
msgs, rep := AssembleInput(prof, eff, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil)
|
||||
msgs, rep := AssembleInput(prof, eff, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
last := msgs[len(msgs)-1]
|
||||
if got := strings.Count(last.Content, "甲"); got != 100 {
|
||||
t.Errorf("injected chars = %d, want 100 (configured limit)", got)
|
||||
@@ -58,7 +58,7 @@ func TestAssembleInputAttachmentNoTruncationUnderLimit(t *testing.T) {
|
||||
Meta: core.DocMeta{Filename: "b.txt"},
|
||||
Blocks: []core.DocBlock{{Type: core.BlockPara, Text: strings.Repeat("乙", 100)}},
|
||||
}
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil)
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
if len(rep.Truncations) != 0 {
|
||||
t.Errorf("under-limit doc should not truncate: %v", rep.Truncations)
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func TestAssembleInputCardBackfill(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("把第二章改短"), nil, nil, nil, nil, resolve, nil)
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("把第二章改短"), nil, nil, nil, nil, resolve, nil, AssembleOptions{})
|
||||
|
||||
var assistantIn string
|
||||
for _, m := range msgs {
|
||||
@@ -128,7 +128,7 @@ func TestAssembleInputCardTruncationAndFallback(t *testing.T) {
|
||||
}},
|
||||
}
|
||||
eff := core.EffectiveSettings{"context": map[string]any{"maxCardChars": float64(100)}}
|
||||
msgs, rep := AssembleInput(prof, eff, hist, fakeUser("继续"), nil, nil, nil, nil, func(id core.ID) *core.ActivityCard { return &card }, nil)
|
||||
msgs, rep := AssembleInput(prof, eff, hist, fakeUser("继续"), nil, nil, nil, nil, func(id core.ID) *core.ActivityCard { return &card }, nil, AssembleOptions{})
|
||||
if strings.Count(msgs[1].Content, "丁") != 100 {
|
||||
t.Errorf("card chars = %d, want 100 (configured limit)", strings.Count(msgs[1].Content, "丁"))
|
||||
}
|
||||
@@ -137,7 +137,7 @@ func TestAssembleInputCardTruncationAndFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
// 无 resolver(收件箱关闭/卡片缺失):占位可见。
|
||||
msgs2, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil)
|
||||
msgs2, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
if !strings.Contains(msgs2[1].Content, "内容见收件箱") {
|
||||
t.Errorf("fallback placeholder missing: %q", msgs2[1].Content)
|
||||
}
|
||||
@@ -159,8 +159,8 @@ func TestAssembleInputArtifactBackfill(t *testing.T) {
|
||||
}
|
||||
return core.Artifact{}, nil, false
|
||||
}
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve)
|
||||
if !strings.Contains(msgs[1].Content, "[产出工件 检索汇总.md]") || !strings.Contains(msgs[1].Content, "关键结论一。") {
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve, AssembleOptions{})
|
||||
if !strings.Contains(msgs[1].Content, "此前已生成文件 检索汇总.md,内容如下:") || !strings.Contains(msgs[1].Content, "关键结论一。") {
|
||||
t.Errorf("artifact body not backfilled: %q", msgs[1].Content)
|
||||
}
|
||||
if !contains(rep.Items, "artifact(1)") {
|
||||
@@ -172,7 +172,7 @@ func TestAssembleInputArtifactBackfill(t *testing.T) {
|
||||
eff := core.EffectiveSettings{"context": map[string]any{"maxCardChars": float64(100)}}
|
||||
msgs2, rep2 := AssembleInput(prof, eff, hist, fakeUser("继续"), nil, nil, nil, nil, nil, func(core.ID) (core.Artifact, []byte, bool) {
|
||||
return art, []byte(long), true
|
||||
})
|
||||
}, AssembleOptions{})
|
||||
if strings.Count(msgs2[1].Content, "乙") != 100 {
|
||||
t.Errorf("artifact chars = %d, want 100", strings.Count(msgs2[1].Content, "乙"))
|
||||
}
|
||||
@@ -183,14 +183,14 @@ func TestAssembleInputArtifactBackfill(t *testing.T) {
|
||||
// 二进制内容:占位不回灌乱码。
|
||||
msgs3, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, func(core.ID) (core.Artifact, []byte, bool) {
|
||||
return art, []byte{0xff, 0xfe, 0x00, 0x01}, true
|
||||
})
|
||||
}, AssembleOptions{})
|
||||
if !strings.Contains(msgs3[1].Content, "二进制内容") || strings.Contains(msgs3[1].Content, "关键结论") {
|
||||
t.Errorf("binary artifact should keep placeholder: %q", msgs3[1].Content)
|
||||
}
|
||||
|
||||
// 无 resolver:占位可见。
|
||||
msgs4, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil)
|
||||
if !strings.Contains(msgs4[1].Content, "[产出工件 检索汇总.md]") {
|
||||
msgs4, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
if !strings.Contains(msgs4[1].Content, "此前已生成文件:检索汇总.md。") {
|
||||
t.Errorf("artifact placeholder missing without resolver: %q", msgs4[1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,9 @@ func FinishAssembly(msgs []agentllm.Message, rep *Report, tail Tail) ([]agentllm
|
||||
return msgs, rep
|
||||
}
|
||||
|
||||
// TailNotes 导出尾注渲染(转录权威模式下由调用方经 Reminders 逐轮注入)。
|
||||
func TailNotes(tail Tail) []string { return tailNotes(tail) }
|
||||
|
||||
func tailNotes(tail Tail) []string {
|
||||
var notes []string
|
||||
if text := strings.TrimSpace(tail.Instructions); text != "" {
|
||||
@@ -207,12 +210,12 @@ func trimOldest(msgs []agentllm.Message, window, reserve int, rep *Report) []age
|
||||
}
|
||||
dropped := 0
|
||||
for estimateAll(msgs) > limit {
|
||||
idx := oldestDrop(msgs)
|
||||
if idx < 0 {
|
||||
start, end := oldestDropRange(msgs)
|
||||
if start < 0 {
|
||||
break
|
||||
}
|
||||
msgs = append(msgs[:idx], msgs[idx+1:]...)
|
||||
dropped++
|
||||
dropped += end - start
|
||||
msgs = append(msgs[:start], msgs[end:]...)
|
||||
}
|
||||
if dropped > 0 {
|
||||
rep.Truncations = append(rep.Truncations, fmtDropped(dropped))
|
||||
@@ -226,9 +229,14 @@ func fmtDropped(n int) string {
|
||||
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
|
||||
func oldestDrop(msgs []agentllm.Message) int {
|
||||
// oldestDropRange 返回下一次丢弃的区间 [start, end)(找不到返回 -1,-1)。
|
||||
// 丢弃按工具组对齐:带 tool_calls 的 assistant 与其连续 tool 结果同退,
|
||||
// 开头的孤立 tool 结果组整组同退——裁切后既不剩无结果的调用,
|
||||
// 也不剩无调用的结果(与库层 alignToolTail 同语义、方向相反)。
|
||||
// 组越过最后一条(当前轮)时不再丢弃:窗口允许略超,配对不可破坏。
|
||||
func oldestDropRange(msgs []agentllm.Message) (int, int) {
|
||||
if len(msgs) < 3 {
|
||||
return -1
|
||||
return -1, -1
|
||||
}
|
||||
// 保留开头的系统前缀和最后一条(当前轮)。
|
||||
start := 0
|
||||
@@ -236,9 +244,26 @@ func oldestDrop(msgs []agentllm.Message) int {
|
||||
start++
|
||||
}
|
||||
if start >= len(msgs)-1 {
|
||||
return -1
|
||||
return -1, -1
|
||||
}
|
||||
return start
|
||||
end := start + 1
|
||||
m := msgs[start]
|
||||
switch {
|
||||
case m.Role == agentllm.RoleAssistant && len(m.ToolCalls) > 0:
|
||||
// assistant 及其连续 tool 结果整组同退。
|
||||
for end < len(msgs) && msgs[end].Role == agentllm.RoleTool {
|
||||
end++
|
||||
}
|
||||
case m.Role == agentllm.RoleTool:
|
||||
// 开头即孤立 tool 结果(其 assistant 必已被丢或不存在):整组同退。
|
||||
for end < len(msgs) && msgs[end].Role == agentllm.RoleTool {
|
||||
end++
|
||||
}
|
||||
}
|
||||
if end > len(msgs)-1 {
|
||||
return -1, -1
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
|
||||
func estimateAll(msgs []agentllm.Message) int {
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestVisibleHistoryDropsExcludedAndUnselectedVariants(t *testing.T) {
|
||||
{ID: "a1", Role: core.RoleAssistant, VariantGroup: "g", VariantIndex: 0, Excluded: true, Parts: []core.Part{{Type: core.PartText, Text: "旧"}}},
|
||||
{ID: "a2", Role: core.RoleAssistant, VariantGroup: "g", VariantIndex: 1, Parts: []core.Part{{Type: core.PartText, Text: "新"}}},
|
||||
}
|
||||
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, core.Message{ID: "cur", Role: core.RoleUser}, nil, nil, nil, nil, nil, nil)
|
||||
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, core.Message{ID: "cur", Role: core.RoleUser}, nil, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
blob := ""
|
||||
for _, m := range msgs {
|
||||
blob += m.Content
|
||||
@@ -62,3 +62,118 @@ func TestTrimDropsOldestHistory(t *testing.T) {
|
||||
t.Fatal("expected truncation")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- N2 WP1:裁切工具组对齐(验收② 产品侧机器证据)----
|
||||
|
||||
// assertNoOrphanToolPairs 双向断言:每个 tool_call 有结果,每个结果有调用。
|
||||
func assertNoOrphanToolPairs(t *testing.T, msgs []agentllm.Message) {
|
||||
t.Helper()
|
||||
calls := map[string]bool{}
|
||||
results := map[string]bool{}
|
||||
for _, m := range msgs {
|
||||
for _, c := range m.ToolCalls {
|
||||
calls[c.ID] = true
|
||||
}
|
||||
if m.Role == agentllm.RoleTool && m.ToolCallID != "" {
|
||||
results[m.ToolCallID] = true
|
||||
}
|
||||
}
|
||||
for id := range calls {
|
||||
if !results[id] {
|
||||
t.Fatalf("孤立 tool_call:%s(无结果)", id)
|
||||
}
|
||||
}
|
||||
for id := range results {
|
||||
if !calls[id] {
|
||||
t.Fatalf("悬空 tool 结果:%s(无调用)", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toolMsgs() []agentllm.Message {
|
||||
call := func(id, name string) agentllm.ToolCall {
|
||||
return agentllm.ToolCall{ID: id, Name: name, Arguments: "{}"}
|
||||
}
|
||||
return []agentllm.Message{
|
||||
{Role: agentllm.RoleSystem, Content: "s"},
|
||||
{Role: agentllm.RoleUser, Content: strings.Repeat("旧一", 300)},
|
||||
{Role: agentllm.RoleAssistant, ToolCalls: []agentllm.ToolCall{call("tc1", "read")}},
|
||||
{Role: agentllm.RoleTool, ToolCallID: "tc1", Name: "read", Content: strings.Repeat("结果一", 200)},
|
||||
{Role: agentllm.RoleUser, Content: strings.Repeat("旧二", 300)},
|
||||
{Role: agentllm.RoleAssistant, ToolCalls: []agentllm.ToolCall{call("tc2", "grep"), call("tc3", "read")}},
|
||||
{Role: agentllm.RoleTool, ToolCallID: "tc2", Name: "grep", Content: strings.Repeat("结果二", 200)},
|
||||
{Role: agentllm.RoleTool, ToolCallID: "tc3", Name: "read", Content: strings.Repeat("结果三", 200)},
|
||||
{Role: agentllm.RoleUser, Content: "现在"},
|
||||
}
|
||||
}
|
||||
|
||||
// 连续多轮裁切后配对仍成立:assistant 与其结果同退,不逐条拆散。
|
||||
func TestTrimOldestKeepsToolPairs(t *testing.T) {
|
||||
out, rep := FinishAssembly(toolMsgs(), &Report{}, Tail{Window: 1600})
|
||||
assertNoOrphanToolPairs(t, out)
|
||||
if len(rep.Truncations) == 0 {
|
||||
t.Fatal("expected truncation")
|
||||
}
|
||||
if out[len(out)-1].Content != "现在" {
|
||||
t.Fatal("当前轮必须保留")
|
||||
}
|
||||
// 窗口再压:旧二组也须整组同退,仍不出现孤立配对。
|
||||
out2, _ := FinishAssembly(toolMsgs(), &Report{}, Tail{Window: 900})
|
||||
assertNoOrphanToolPairs(t, out2)
|
||||
}
|
||||
|
||||
// 开头即孤立 tool 结果组:整组同退(其 assistant 已不存在)。
|
||||
func TestTrimOldestDropsLeadingOrphanToolRun(t *testing.T) {
|
||||
msgs := []agentllm.Message{
|
||||
{Role: agentllm.RoleSystem, Content: "s"},
|
||||
{Role: agentllm.RoleTool, ToolCallID: "ghost", Name: "read", Content: strings.Repeat("孤", 500)},
|
||||
{Role: agentllm.RoleUser, Content: strings.Repeat("旧", 500)},
|
||||
{Role: agentllm.RoleUser, Content: "现在"},
|
||||
}
|
||||
out, _ := FinishAssembly(msgs, &Report{}, Tail{Window: 700})
|
||||
assertNoOrphanToolPairs(t, out)
|
||||
for _, m := range out {
|
||||
if m.ToolCallID == "ghost" {
|
||||
t.Fatal("孤立结果组应整组丢弃")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 工具组越过当前轮时不再丢弃:窗口可略超,配对不可破坏。
|
||||
func TestTrimOldestStopsWhenGroupCrossesCurrentTurn(t *testing.T) {
|
||||
msgs := []agentllm.Message{
|
||||
{Role: agentllm.RoleSystem, Content: "s"},
|
||||
{Role: agentllm.RoleAssistant, ToolCalls: []agentllm.ToolCall{{ID: "tc9", Name: "read", Arguments: "{}"}}},
|
||||
{Role: agentllm.RoleTool, ToolCallID: "tc9", Name: "read", Content: strings.Repeat("结果", 900)},
|
||||
}
|
||||
start, end := oldestDropRange(msgs)
|
||||
if start != -1 || end != -1 {
|
||||
t.Fatalf("组越过当前轮应返回 -1,-1,得到 %d,%d", start, end)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- N2 WP1:PrefixHash 稳定契约(同一输入 → 同一前缀哈希)----
|
||||
|
||||
func TestPrefixHashStableForIdenticalInput(t *testing.T) {
|
||||
build := func() []agentllm.Message {
|
||||
return []agentllm.Message{
|
||||
{Role: agentllm.RoleSystem, Content: "系统前缀A"},
|
||||
{Role: agentllm.RoleSystem, Content: "系统前缀B"},
|
||||
{Role: agentllm.RoleUser, Content: "历史一"},
|
||||
{Role: agentllm.RoleAssistant, Content: "回复一"},
|
||||
{Role: agentllm.RoleUser, Content: "现在"},
|
||||
}
|
||||
}
|
||||
_, rep1 := FinishAssembly(build(), &Report{}, Tail{Instructions: "个人指令"})
|
||||
_, rep2 := FinishAssembly(build(), &Report{}, Tail{Instructions: "个人指令"})
|
||||
if rep1.PrefixHash == "" || rep1.PrefixHash != rep2.PrefixHash {
|
||||
t.Fatalf("同一输入的前缀哈希必须一致:%q vs %q", rep1.PrefixHash, rep2.PrefixHash)
|
||||
}
|
||||
// 前缀变化必须改变哈希(钉住哈希语义,防退化为常量)。
|
||||
other := build()
|
||||
other[0].Content = "系统前缀X"
|
||||
_, rep3 := FinishAssembly(other, &Report{}, Tail{Instructions: "个人指令"})
|
||||
if rep3.PrefixHash == rep1.PrefixHash {
|
||||
t.Fatal("前缀变化后哈希必须不同")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ func assistantWith(id string, parts ...core.Part) core.Message {
|
||||
}
|
||||
|
||||
func assembleHistory(hist []core.Message, resolve ArtifactResolver) []agentllm.Message {
|
||||
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve)
|
||||
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve, AssembleOptions{})
|
||||
if len(msgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestSummarizeBeyondInjectsExtract(t *testing.T) {
|
||||
}
|
||||
current := core.Message{ID: "cur", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "现在"}}}
|
||||
prof := core.AgentProfile{Context: core.ContextPolicy{MaxHistoryMessages: 2, SummarizeBeyond: true}}
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, history, current, nil, nil, nil, nil, nil, nil)
|
||||
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, history, current, nil, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
joined := ""
|
||||
for _, m := range msgs {
|
||||
joined += m.Content
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package ctxasm
|
||||
|
||||
// U3/R6:已保存页面的语义投影并入上下文——下一轮模型能看到结构、来源与修订
|
||||
// 入口(present + baseRevision),可据此解释并修改已有 table/compare/tabs。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func surfaceSnap() core.SurfaceSnapshot {
|
||||
return core.SurfaceSnapshot{
|
||||
Identity: core.SurfaceIdentity{SurfaceID: "surf_x", Owner: "alice", PluginID: "generative-ui", ConversationID: "c1"},
|
||||
Revision: 2,
|
||||
Spec: core.SurfaceSpec{Title: "选型对比", Nodes: []core.UINode{
|
||||
{Type: core.UISection, ID: "n1", Title: "概览", Children: []core.UINode{
|
||||
{Type: core.UIMarkdown, ID: "n1a", Source: []string{"doc:a"}, Markdown: "结论"},
|
||||
}},
|
||||
{Type: core.UITable, ID: "n2", Source: []string{"doc:a", "doc:b"}},
|
||||
}},
|
||||
SemanticText: "三工具对比页",
|
||||
}
|
||||
}
|
||||
|
||||
func TestSurfaceProjectionInContext(t *testing.T) {
|
||||
msgs, rep := AssembleInput(core.AgentProfile{}, nil, nil,
|
||||
core.Message{Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "改一下"}}},
|
||||
nil, nil, nil, nil, nil, nil, AssembleOptions{Surfaces: []core.SurfaceSnapshot{surfaceSnap()}})
|
||||
joined := make([]string, len(msgs))
|
||||
for i, m := range msgs {
|
||||
joined[i] = m.Content
|
||||
}
|
||||
all := strings.Join(joined, "\n---\n")
|
||||
for _, need := range []string{"surf_x", "修订 2", "选型对比", "section(\"概览\")#n1", "markdown#n1a[doc:a]", "table#n2", "baseRevision=2"} {
|
||||
if !strings.Contains(all, need) {
|
||||
t.Fatalf("projection missing %q in assembled context:\n%s", need, all)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, it := range rep.Items {
|
||||
if strings.HasPrefix(it, "surface(") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("report should count surface injection: %v", rep.Items)
|
||||
}
|
||||
_ = agentllm.RoleSystem
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package ctxasm
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 交错思考模型(MiniMax 等)要求历史 assistant 原样回传 <think>;
|
||||
// 关闭时不回传(默认兼容旧行为)。思考块只进请求,不落库。
|
||||
func TestThinkReplayWrapsAssistantHistory(t *testing.T) {
|
||||
hist := []core.Message{
|
||||
{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "总结"}}},
|
||||
{ID: "m2", Role: core.RoleAssistant, Parts: []core.Part{
|
||||
{Type: core.PartThinking, Text: "先读材料。", Process: true},
|
||||
{Type: core.PartText, Text: "结论是先入库。", Process: true},
|
||||
{Type: core.PartToolCall, ToolCall: &core.ToolCallPart{ID: "c1", Name: "run_python", Arguments: "{}"}},
|
||||
{Type: core.PartToolResult, ToolResult: &core.ToolResultPart{CallID: "c1", Status: "ok", Summary: "done"}},
|
||||
{Type: core.PartText, Text: "已完成。"},
|
||||
}},
|
||||
}
|
||||
on, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil, AssembleOptions{ThinkReplay: true})
|
||||
var sawThink int
|
||||
for _, m := range on {
|
||||
if m.Role == agentllm.RoleAssistant && strings.HasPrefix(m.Content, "<think>") {
|
||||
sawThink++
|
||||
if !strings.Contains(m.Content, "</think>") || !strings.Contains(m.Content, "先读材料。") {
|
||||
t.Fatalf("think block malformed: %q", m.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
if sawThink == 0 {
|
||||
t.Fatalf("ThinkReplay=on should inject <think> into assistant history: %+v", on)
|
||||
}
|
||||
off, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil, AssembleOptions{})
|
||||
for _, m := range off {
|
||||
if m.Role == agentllm.RoleAssistant && strings.Contains(m.Content, "<think>") {
|
||||
t.Fatalf("ThinkReplay=off must not inject think blocks: %q", m.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package genui
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AutoBudgetPerHour 是每任务(conversation)滑动一小时内允许的自动建议次数。
|
||||
const AutoBudgetPerHour = 3
|
||||
|
||||
// AutoCooldown 是同会话两次自动触发的最小间隔。
|
||||
const AutoCooldown = 60 * time.Second
|
||||
|
||||
// SettingAutoPresent 是 owner 级 opt-in 设置键(默认 false;与 activation 分离)。
|
||||
const SettingAutoPresent = "plugins.generative-ui.autoPresent"
|
||||
|
||||
// AutoReminder 是注入 system-reminder 的建议文案(只引导 present,禁止改任务/点动作)。
|
||||
const AutoReminder = "任务语义事件已发生(阶段或材料/工件完成)。若 generative-ui 已启用,请调用一次 generative-ui.present 生成或修订建议页,按当前任务阶段组织信息。不要点击页面 actions,不要改写 plan/todo/任务完成状态;任务事实只能来自真实任务记录。"
|
||||
|
||||
// ActivationLookup 查询 owner 的 Surface activation(generative-ui)。
|
||||
type ActivationLookup func(owner string) (enabled bool)
|
||||
|
||||
// SettingsLookup 查询 owner 的 autoPresent opt-in。
|
||||
type SettingsLookup func(owner string) (enabled bool)
|
||||
|
||||
// Auditor 写审计事件(耗尽等);nil = 跳过。
|
||||
type Auditor func(owner, kind, detail string)
|
||||
|
||||
// Auto 是自动触发闸门:opt-in ∧ activation ∧ 预算 ∧ 冷却。
|
||||
// 触发源由 runmgr 显式调用;present/surface/action 路径不得调用。
|
||||
type Auto struct {
|
||||
activation ActivationLookup
|
||||
settings SettingsLookup
|
||||
audit Auditor
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
budget map[string][]time.Time // convID → 触发时刻
|
||||
last map[string]time.Time // convID → 上次成功放行
|
||||
}
|
||||
|
||||
// NewAuto 构造闸门(lookup 不得为 nil)。
|
||||
func NewAuto(activation ActivationLookup, settings SettingsLookup, audit Auditor) *Auto {
|
||||
return &Auto{
|
||||
activation: activation,
|
||||
settings: settings,
|
||||
audit: audit,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
budget: map[string][]time.Time{},
|
||||
last: map[string]time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
// Try 尝试放行一次自动建议。ok=false 时 skip 含原因(供测试);ok=true 时调用方应注入 AutoReminder。
|
||||
func (a *Auto) Try(owner, convID, reason string) (ok bool, skip string) {
|
||||
if a == nil || a.activation == nil || a.settings == nil {
|
||||
return false, "unconfigured"
|
||||
}
|
||||
if owner == "" || convID == "" {
|
||||
return false, "missing_ids"
|
||||
}
|
||||
if !a.settings(owner) {
|
||||
return false, "opt_in_off"
|
||||
}
|
||||
if !a.activation(owner) {
|
||||
return false, "activation_off"
|
||||
}
|
||||
|
||||
now := a.now()
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if t, hit := a.last[convID]; hit && now.Sub(t) < AutoCooldown {
|
||||
return false, "cooldown"
|
||||
}
|
||||
|
||||
cutoff := now.Add(-time.Hour)
|
||||
kept := a.budget[convID][:0]
|
||||
for _, t := range a.budget[convID] {
|
||||
if t.After(cutoff) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
a.budget[convID] = kept
|
||||
if len(kept) >= AutoBudgetPerHour {
|
||||
if a.audit != nil {
|
||||
a.audit(owner, "genui.auto_present_exhausted", "conversation="+convID+" reason="+reason)
|
||||
}
|
||||
return false, "budget"
|
||||
}
|
||||
|
||||
a.budget[convID] = append(kept, now)
|
||||
a.last[convID] = now
|
||||
return true, ""
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package genui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAutoDefaultOff(t *testing.T) {
|
||||
a := NewAuto(func(string) bool { return true }, func(string) bool { return false }, nil)
|
||||
if ok, skip := a.Try("alice", "c1", "plan"); ok || skip != "opt_in_off" {
|
||||
t.Fatalf("want opt_in_off, got ok=%v skip=%q", ok, skip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoRequiresActivation(t *testing.T) {
|
||||
a := NewAuto(func(string) bool { return false }, func(string) bool { return true }, nil)
|
||||
if ok, skip := a.Try("alice", "c1", "plan"); ok || skip != "activation_off" {
|
||||
t.Fatalf("want activation_off, got ok=%v skip=%q", ok, skip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoBudgetAndCooldown(t *testing.T) {
|
||||
var exhausted int
|
||||
now := time.Unix(1_700_000_000, 0).UTC()
|
||||
a := NewAuto(func(string) bool { return true }, func(string) bool { return true },
|
||||
func(owner, kind, detail string) {
|
||||
if kind == "genui.auto_present_exhausted" {
|
||||
exhausted++
|
||||
}
|
||||
})
|
||||
a.now = func() time.Time { return now }
|
||||
|
||||
for i := 0; i < AutoBudgetPerHour; i++ {
|
||||
ok, skip := a.Try("alice", "c1", "plan")
|
||||
if !ok {
|
||||
t.Fatalf("shot %d should pass, skip=%q", i, skip)
|
||||
}
|
||||
now = now.Add(AutoCooldown + time.Second)
|
||||
}
|
||||
if ok, skip := a.Try("alice", "c1", "plan"); ok || skip != "budget" || exhausted != 1 {
|
||||
t.Fatalf("want budget exhausted, ok=%v skip=%q exhausted=%d", ok, skip, exhausted)
|
||||
}
|
||||
|
||||
// 冷却:同一时刻第二次拒绝
|
||||
a2 := NewAuto(func(string) bool { return true }, func(string) bool { return true }, nil)
|
||||
a2.now = func() time.Time { return now }
|
||||
if ok, _ := a2.Try("alice", "c2", "art"); !ok {
|
||||
t.Fatal("first should pass")
|
||||
}
|
||||
if ok, skip := a2.Try("alice", "c2", "art"); ok || skip != "cooldown" {
|
||||
t.Fatalf("want cooldown, got ok=%v skip=%q", ok, skip)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
// Package genui 是 Generative UI 的内置进程内插件(提案 U3):
|
||||
// 当前 Agent 经 exposeAsTool 的 generative-ui.present 命令提交声明式页面,
|
||||
// 宿主(EXT-17 SurfaceHost)校验并原子发布;目录=提示=校验三同源。
|
||||
// 不接管对话循环、模型凭据或任务执行权(KTD2);不另造 Agent runtime。
|
||||
package genui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// PluginID 是插件身份(activation 台账与命令前缀共用)。
|
||||
const PluginID = "generative-ui"
|
||||
|
||||
const (
|
||||
// CommandPresent 是页面提交命令(revise 即带 surfaceId+baseRevision 的再次 present)。
|
||||
CommandPresent = PluginID + ".present"
|
||||
// CommandSubmit 是 U5 已批准的 C 类页面动作(表单数据落插件 KV 台账;
|
||||
// 不 exposeAsTool——按钮专用,模型不可直接调用)。
|
||||
CommandSubmit = PluginID + ".submit"
|
||||
// ActionHelp / ActionIntent 是 B 类首批加白(独立评审:builtin.help、system.intent)。
|
||||
ActionHelp = "builtin.help"
|
||||
ActionIntent = "system.intent"
|
||||
)
|
||||
|
||||
// AllowedActionIDs 是 present 校验与 surfacesvc.AllowedActions 的开放动作集合(须同步)。
|
||||
var AllowedActionIDs = map[string]struct{}{
|
||||
CommandSubmit: {},
|
||||
ActionHelp: {},
|
||||
ActionIntent: {},
|
||||
}
|
||||
|
||||
// Catalog 是组件目录与纪律(U1 试验 CATALOG_PROMPT 的 Go 权威版;工具描述携带,
|
||||
// 模型与校验器看到同一份规则)。修改需同步 core.ValidateSurfaceSpec 与目录版本。
|
||||
const Catalog = `何时用本工具:用户要求"整理成一页 / 做个对比 / 出一页报告 / 看板 / 时间线"这类**页面化交付**时,优先调用本工具提交声明式页面,而不是写文件工件——页面会在用户预览区直接打开并可继续修订。纯文本问答、简短列表、代码交付保持原方式,不强行出页。
|
||||
任务工作页组织(用户在任务/项目工作区查看时):按任务阶段决定页面结构——需求阶段突出待澄清项与决策点(form/kv 优先);执行阶段突出步骤、进度与产物(steps/timeline/table 优先);验收阶段突出验证结果与缺项(kv+markdown 结论优先)。任务完成状态、步骤勾选等**事实字段只能来自对话上下文中的真实任务记录**(计划/待办/验证结果),你不能凭页面渲染改变任务状态——只做事实的组织与呈现。
|
||||
|
||||
可用节点类型(16 种;字段必须严格匹配,禁止目录外类型/字段):
|
||||
section{title?,children?};list{items:string[]};kv{kv:[[k,v]]};
|
||||
form{fields:[{key,label,type:text|textarea|number|boolean|select,options?(select必填),required?,placeholder?,help?,default?}]};
|
||||
markdown{markdown};doc-ref{docId}(须为会话内存在的工件ID);
|
||||
actions{actions:[{id,label,style?:primary|default|danger}]}(不带command);
|
||||
chart-lite{chart:{kind:bar|line,labels:[],series:[]等长}};avatar{avatar:{name,sub?,tone?:ok|warn|danger|info,stack?}};
|
||||
progress{progress:{label?,value:0~1}};compare{compare:{left:{title?,tone?,nodes?},right:{同left}}};
|
||||
tree{tree:{nodes:[{id,label,tone?,badge?,count?,children?}]}};
|
||||
table{table:{columns:[{key,label}],rows:[{id?,cells:[[key,值]与columns等长同序}]}};
|
||||
steps{steps:{current:0起,steps:[{label,status?:pending|active|done|failed,tone?}]}};
|
||||
tabs{tabs:{tabs:[{id,label,nodes?}],active?}};timeline{timeline:{items:[{title,time?,tone?,markdown?,ref?}]}}。
|
||||
规则:每节点可带 id(整页唯一稳定)与 source[](支撑事实的来源标识);事实性内容标注来源,
|
||||
分析/建议只放 markdown 且不加 source;≤200 节点、≤8 层、≤256KiB;未知类型/字段与未知
|
||||
doc-ref 会被拒绝并返回字段级错误(可修正后重提一次)。结构服务目标:比较→compare/table;
|
||||
风险→按严重度组织;顺序→steps/timeline;收集输入→form。
|
||||
actions 节点纪律:action 的 id 仅允许开放白名单——generative-ui.submit(C 类表单提交)、
|
||||
builtin.help(B 类只读帮助)、system.intent(B 类意图匹配;button 入口只返回匹配结果、
|
||||
不嵌套分发业务命令)。引用其他 action id 会被拒绝;按钮点击由用户发起,宿主经命令总线分发并审计。
|
||||
最小完整示例(spec 参数的形状;title 与 nodes 在顶层,source 挂在节点上):
|
||||
{"title":"示例页","nodes":[
|
||||
{"type":"section","id":"n1","title":"结论","children":[
|
||||
{"type":"markdown","id":"n1a","source":["doc:m1"],"markdown":"结论先行。"}]},
|
||||
{"type":"table","id":"n2","table":{"columns":[{"key":"k","label":"维度"},{"key":"v","label":"值"}],
|
||||
"rows":[{"id":"r1","cells":[["k","报价"],["v","¥8/人/天"]]}]}},
|
||||
{"type":"steps","id":"n3","steps":{"current":0,"steps":[{"label":"评审","status":"pending"}]}}]}`
|
||||
|
||||
func init() {
|
||||
extension.RegisterCompiled(extension.InProc{ID: PluginID, Register: register})
|
||||
}
|
||||
|
||||
func register(reg extension.Registry) error {
|
||||
spec := json.RawMessage(fmt.Sprintf(`{
|
||||
"id": %q,
|
||||
"title": "生成页面",
|
||||
"description": %q,
|
||||
"paramsSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"spec": {"type": "object", "description": "页面规格:顶层恰为 {title: string, nodes: [节点数组]}",
|
||||
"properties": {"title": {"type": "string"}, "nodes": {"type": "array", "items": {"type": "object"}}},
|
||||
"required": ["title", "nodes"]},
|
||||
"surfaceId": {"type": "string", "description": "修订已有页面时传入"},
|
||||
"baseRevision": {"type": "integer", "description": "修订基于的当前修订号"},
|
||||
"summary": {"type": "string", "description": "一句话语义摘要(回放与索引用)"}
|
||||
},
|
||||
"required": ["spec"]
|
||||
},
|
||||
"exposeAsTool": {"risk": "low"}
|
||||
}`, CommandPresent, "提交一页声明式页面(由宿主校验、保存并在预览区渲染)。"+Catalog))
|
||||
if err := reg.AddCommandHandler(spec, present); err != nil {
|
||||
return err
|
||||
}
|
||||
submitSpec := json.RawMessage(`{
|
||||
"id": "generative-ui.submit",
|
||||
"title": "提交表单",
|
||||
"description": "页面动作:提交用户填写的表单数据(落插件台账;按钮触发,不经模型)",
|
||||
"paramsSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"surfaceId": {"type": "string"},
|
||||
"revision": {"type": "integer"},
|
||||
"requestId": {"type": "string"},
|
||||
"data": {"type": "object"}
|
||||
},
|
||||
"required": ["surfaceId", "requestId", "data"]
|
||||
}
|
||||
}`)
|
||||
return reg.AddCommandHandler(submitSpec, submitForm)
|
||||
}
|
||||
|
||||
// submitForm 是 C 类动作处理器:表单数据落插件自有 KV 台账(键含 requestId
|
||||
// 天然幂等;已批准范围限定 KV+任务台账,不建业务事实表)。经命令总线四入口
|
||||
// 同源:审计信封与(若未来升档)高危确认卡由总线收口。
|
||||
func submitForm(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
|
||||
surfaceID, _ := req.Params["surfaceId"].(string)
|
||||
requestID, _ := req.Params["requestId"].(string)
|
||||
if surfaceID == "" || requestID == "" {
|
||||
return nil, fmt.Errorf("generative-ui.submit: surfaceId and requestId required")
|
||||
}
|
||||
data, _ := json.Marshal(req.Params["data"])
|
||||
key := "form:" + surfaceID + ":" + requestID
|
||||
if err := h.KVPut(key, string(data)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := h.Audit("surface.form_submitted", surfaceID, "requestId="+requestID+" bytes="+fmt.Sprint(len(data))); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"ok": true, "stored": true, "key": key}, nil
|
||||
}
|
||||
|
||||
// ValidateActionIDs 供 present 校验:actions 节点引用的 id 必须在已开放集合内。
|
||||
func ValidateActionIDs(spec []byte) []string {
|
||||
var s struct {
|
||||
Nodes []core.UINode `json:"nodes"`
|
||||
}
|
||||
if err := json.Unmarshal(spec, &s); err != nil {
|
||||
return nil
|
||||
}
|
||||
var errs []string
|
||||
var walk func(n core.UINode, path string)
|
||||
walk = func(n core.UINode, path string) {
|
||||
if n.Type == core.UIActions {
|
||||
for _, a := range n.Actions {
|
||||
if _, ok := AllowedActionIDs[a.ID]; !ok {
|
||||
errs = append(errs, fmt.Sprintf("%s: 未知动作 id %q(不在开放白名单)", path, a.ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, c := range n.Children {
|
||||
walk(c, fmt.Sprintf("%s/child[%d]", path, i))
|
||||
}
|
||||
if n.Compare != nil {
|
||||
for side, sd := range map[string]core.UICompareSide{"left": n.Compare.Left, "right": n.Compare.Right} {
|
||||
for i, c := range sd.Nodes {
|
||||
walk(c, fmt.Sprintf("%s/%s[%d]", path, side, i))
|
||||
}
|
||||
}
|
||||
}
|
||||
if n.Tabs != nil {
|
||||
for i, tb := range n.Tabs.Tabs {
|
||||
for j, c := range tb.Nodes {
|
||||
walk(c, fmt.Sprintf("%s/tab[%d][%d]", path, i, j))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, n := range s.Nodes {
|
||||
walk(n, fmt.Sprintf("$[%d]", i))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// present 是命令处理器:经 owner 作用域 Host 的 SurfaceHost 能力提交页面。
|
||||
// 能力缺失(宿主未装配)与未启用明确报错;doc-ref 经 owner 作用域工件解析做
|
||||
// 真实授权(伪造引用拒绝);校验失败返回字段级清单供模型修复重提(至多一次)。
|
||||
func present(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
|
||||
sh, ok := h.(extension.SurfaceHost)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("generative-ui: surface capability not available in this host")
|
||||
}
|
||||
enabled, epoch, err := sh.SurfaceActivation(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !enabled {
|
||||
return nil, fmt.Errorf("generative-ui: 插件未启用(owner 未开启 Surface 能力),本次提交被拒绝")
|
||||
}
|
||||
raw, _ := json.Marshal(normalizeSpecParam(req.Params["spec"]))
|
||||
surfaceID, _ := req.Params["surfaceId"].(string)
|
||||
base := 0
|
||||
if v, ok := req.Params["baseRevision"].(float64); ok {
|
||||
base = int(v)
|
||||
}
|
||||
summary, _ := req.Params["summary"].(string)
|
||||
|
||||
// doc-ref 真实授权:spec 里的 docId 必须是当前 owner 可解析的工件(伪造即拒)。
|
||||
if errs := authorizeDocRefs(raw, h); len(errs) > 0 {
|
||||
return toolError("invalid spec", errs), nil
|
||||
}
|
||||
// 动作白名单(U5 已批准范围):未知动作 id 拒绝并回字段级错误。
|
||||
if errs := ValidateActionIDs(raw); len(errs) > 0 {
|
||||
return toolError("invalid spec", errs), nil
|
||||
}
|
||||
snap, err := sh.SurfaceSave(ctx, extension.SurfaceSaveRequest{
|
||||
SurfaceID: core.ID(surfaceID),
|
||||
ConversationID: core.ID(req.ConversationID),
|
||||
BaseRevision: base,
|
||||
ObservedEpoch: epoch,
|
||||
SemanticText: summary,
|
||||
Spec: raw,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), "surface: invalid spec:") {
|
||||
details := strings.TrimSpace(strings.TrimPrefix(err.Error(), "surface: invalid spec:"))
|
||||
var list []string
|
||||
for _, d := range strings.Split(details, ";") {
|
||||
if t := strings.TrimSpace(d); t != "" {
|
||||
list = append(list, t)
|
||||
}
|
||||
}
|
||||
return toolError("invalid spec", list), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"ok": true,
|
||||
"surfaceId": string(snap.Identity.SurfaceID),
|
||||
"revision": snap.Revision,
|
||||
"epoch": snap.ActivationEpoch,
|
||||
"summary": snap.SemanticText,
|
||||
"note": "页面已保存;用户在预览区查看。修订时带 surfaceId 与 baseRevision 重提。",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// toolError 是返回给模型的字段级错误(工具结果错误,非分发错误)。
|
||||
type toolErrorBody struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
Details []string `json:"details,omitempty"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
func toolError(msg string, details []string) toolErrorBody {
|
||||
return toolErrorBody{OK: false, Error: msg, Details: details,
|
||||
Note: "按错误修正 spec 后可重提一次;再次失败请改用文本回答。"}
|
||||
}
|
||||
|
||||
// normalizeSpecParam 是插件的 schema 修复策略(r1 KTD1;U3 回放实测的模型偏差
|
||||
// 逐条落地):单键包装({"page":…})解包;顶层 children→nodes;kv/cells 的
|
||||
// 对象形式({"k":…,"v":…}/{"key":…,"value":…})改写为二元组数组。只做结构
|
||||
// 改写,不改内容语义;修复后仍过 core 校验器。
|
||||
func normalizeSpecParam(v any) any {
|
||||
if arr, ok := v.([]any); ok {
|
||||
return map[string]any{"title": "", "nodes": arr}
|
||||
}
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return v
|
||||
}
|
||||
if len(m) == 1 {
|
||||
if inner, ok := m["page"].(map[string]any); ok {
|
||||
m = inner
|
||||
}
|
||||
}
|
||||
if _, ok := m["nodes"]; !ok {
|
||||
if ch, ok := m["children"].([]any); ok {
|
||||
m["nodes"] = ch
|
||||
delete(m, "children")
|
||||
}
|
||||
}
|
||||
// 页级 source(模型的常见引用写法):分发给未自带 source 的节点——保留引用
|
||||
// 信息,不发明内容;节点自带 source 者不受影响。
|
||||
if top, ok := m["source"].([]any); ok {
|
||||
delete(m, "source")
|
||||
if nodes, ok := m["nodes"].([]any); ok {
|
||||
for _, n := range nodes {
|
||||
if nm, ok := n.(map[string]any); ok {
|
||||
if _, has := nm["source"]; !has {
|
||||
cp := make([]any, len(top))
|
||||
copy(cp, top)
|
||||
nm["source"] = cp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
repairChildrenShape(m)
|
||||
// {"item": …} 单键包装在 children 形状修复之后剥:children 的 {"item":{node}}
|
||||
// 由 repairChildrenShape 的对象映射规则兜住,先剥反而会把单子节点打散进
|
||||
// 值数组;kv/cells/items/columns 等其余数组字段在这里统一解到本体。
|
||||
if unwrapped, ok := repairItemWrappers(m).(map[string]any); ok {
|
||||
m = unwrapped
|
||||
}
|
||||
repairPairs(m)
|
||||
repairTableCells(m)
|
||||
return m
|
||||
}
|
||||
|
||||
// repairItemWrappers 递归剥掉 XML-RPC 风格的 {"item": X} 单键包装(2026-10-12
|
||||
// dev 实测 MiniMax-M3,事件 seq 1395/2171):数组字段(kv/cells/items/columns…)
|
||||
// 整字段被包一层 {"item":[…]},数组元素再被包 {"item":["k","v"]}/{"item":"v"}。
|
||||
// 规范词汇无名为 item 的字段(core/ui.go 全量核对),单键 item 不承载语义,
|
||||
// 安全解包;只改结构不改内容。
|
||||
func repairItemWrappers(v any) any {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
if len(t) == 1 {
|
||||
if inner, ok := t["item"]; ok {
|
||||
return repairItemWrappers(inner)
|
||||
}
|
||||
}
|
||||
out := make(map[string]any, len(t))
|
||||
for k, val := range t {
|
||||
out[k] = repairItemWrappers(val)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(t))
|
||||
for i, item := range t {
|
||||
out[i] = repairItemWrappers(item)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// repairTableCells 表格行 cells 的裸标量数组(同场实测):cells 期望与
|
||||
// columns.key 对应的 [key,value] 二元组,模型输出成与列等长的裸值数组。
|
||||
// 长度一致且列键齐全时按列序配对(结构改写,不发明内容);不一致则不动,
|
||||
// 交校验器给字段级错误供模型修复重提。
|
||||
func repairTableCells(node any) {
|
||||
m, ok := node.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if tbl, ok := m["table"].(map[string]any); ok {
|
||||
cols, colsOK := tbl["columns"].([]any)
|
||||
rows, rowsOK := tbl["rows"].([]any)
|
||||
if colsOK && rowsOK {
|
||||
keys := make([]string, len(cols))
|
||||
valid := true
|
||||
for i, c := range cols {
|
||||
cm, ok := c.(map[string]any)
|
||||
if !ok {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
k, ok := cm["key"].(string)
|
||||
if !ok || k == "" {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
keys[i] = k
|
||||
}
|
||||
if valid {
|
||||
for _, r := range rows {
|
||||
rm, ok := r.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
cells, ok := rm["cells"].([]any)
|
||||
if !ok || len(cells) != len(keys) {
|
||||
continue
|
||||
}
|
||||
paired := make([]any, 0, len(cells))
|
||||
convertible := true
|
||||
for i, c := range cells {
|
||||
switch cv := c.(type) {
|
||||
case string:
|
||||
paired = append(paired, []any{keys[i], cv})
|
||||
case float64:
|
||||
paired = append(paired, []any{keys[i], strconv.FormatFloat(cv, 'f', -1, 64)})
|
||||
default:
|
||||
convertible = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if convertible {
|
||||
rm["cells"] = paired
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, v := range m {
|
||||
if vm, ok := v.(map[string]any); ok {
|
||||
repairTableCells(vm)
|
||||
}
|
||||
if va, ok := v.([]any); ok {
|
||||
for _, item := range va {
|
||||
if im, ok := item.(map[string]any); ok {
|
||||
repairTableCells(im)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// repairChildrenShape 递归修复 children 的常见形状偏差(真实模型实测):
|
||||
// children 为对象(按 key 编号的子节点映射)→ 取其值转数组;单对象 → 包一层数组。
|
||||
func repairChildrenShape(node any) {
|
||||
m, ok := node.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ch, ok := m["children"]; ok && ch != nil {
|
||||
switch v := ch.(type) {
|
||||
case map[string]any:
|
||||
arr := make([]any, 0, len(v))
|
||||
for _, val := range v {
|
||||
arr = append(arr, val)
|
||||
}
|
||||
m["children"] = arr
|
||||
case []any:
|
||||
for _, c := range v {
|
||||
if cm, ok := c.(map[string]any); ok {
|
||||
repairChildrenShape(cm)
|
||||
}
|
||||
}
|
||||
default:
|
||||
// 单对象子节点
|
||||
if cm, ok := ch.(map[string]any); ok {
|
||||
m["children"] = []any{cm}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, v := range m {
|
||||
if vm, ok := v.(map[string]any); ok {
|
||||
repairChildrenShape(vm)
|
||||
}
|
||||
if va, ok := v.([]any); ok {
|
||||
for _, item := range va {
|
||||
if im, ok := item.(map[string]any); ok {
|
||||
repairChildrenShape(im)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// repairPairs 递归把 "kv"/"cells" 下的对象项改写为二元组。
|
||||
func repairPairs(node any) {
|
||||
m, ok := node.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for key, val := range m {
|
||||
if (key == "kv" || key == "cells") && val != nil {
|
||||
if arr, ok := val.([]any); ok {
|
||||
fixed := make([]any, 0, len(arr))
|
||||
changed := false
|
||||
for _, item := range arr {
|
||||
if p, ok := pairFromObject(item); ok {
|
||||
fixed = append(fixed, p)
|
||||
changed = true
|
||||
} else {
|
||||
fixed = append(fixed, item)
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
m[key] = fixed
|
||||
}
|
||||
}
|
||||
}
|
||||
repairPairs(val)
|
||||
}
|
||||
}
|
||||
|
||||
// pairFromObject 从 {"k","v"}/{"key","value"}/{"name","value"} 对象提取二元组。
|
||||
func pairFromObject(item any) ([2]any, bool) {
|
||||
obj, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return [2]any{}, false
|
||||
}
|
||||
var k, v any
|
||||
for kk, vv := range obj {
|
||||
switch kk {
|
||||
case "k", "key", "name":
|
||||
k = vv
|
||||
case "v", "value":
|
||||
v = vv
|
||||
default:
|
||||
return [2]any{}, false
|
||||
}
|
||||
}
|
||||
if k == nil || v == nil {
|
||||
return [2]any{}, false
|
||||
}
|
||||
ks, ok1 := k.(string)
|
||||
vs, ok2 := v.(string)
|
||||
if !ok1 || !ok2 {
|
||||
return [2]any{}, false
|
||||
}
|
||||
return [2]any{ks, vs}, true
|
||||
}
|
||||
|
||||
// authorizeDocRefs 收集 spec 中的 doc-ref docId 并经 Host 工件面做 owner 作用域解析。
|
||||
func authorizeDocRefs(raw []byte, h extension.Host) []string {
|
||||
var spec struct {
|
||||
Nodes []core.UINode `json:"nodes"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &spec); err != nil {
|
||||
return []string{"spec: " + err.Error()}
|
||||
}
|
||||
var errs []string
|
||||
var walk func(n core.UINode, path string)
|
||||
walk = func(n core.UINode, path string) {
|
||||
if n.Type == core.UIDocRef && n.DocID != "" {
|
||||
if _, err := h.ArtifactGet(string(n.DocID)); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: doc-ref 引用了不存在或无权限的工件 %q", path, n.DocID))
|
||||
}
|
||||
}
|
||||
for i, c := range n.Children {
|
||||
walk(c, fmt.Sprintf("%s/child[%d]", path, i))
|
||||
}
|
||||
if n.Compare != nil {
|
||||
for side, sd := range map[string]core.UICompareSide{"left": n.Compare.Left, "right": n.Compare.Right} {
|
||||
for i, c := range sd.Nodes {
|
||||
walk(c, fmt.Sprintf("%s/%s[%d]", path, side, i))
|
||||
}
|
||||
}
|
||||
}
|
||||
if n.Tabs != nil {
|
||||
for i, tb := range n.Tabs.Tabs {
|
||||
for j, c := range tb.Nodes {
|
||||
walk(c, fmt.Sprintf("%s/tab[%d][%d]", path, i, j))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, n := range spec.Nodes {
|
||||
walk(n, fmt.Sprintf("$[%d]", i))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package genui
|
||||
|
||||
// U3 红测(提案 U3 验证行):能力缺失明确拒绝;未启用拒绝;伪造 doc-ref 拒绝;
|
||||
// 目录违规返回字段级清单(供模型修复重提);成功路径经 EXT-17 提交并携带观察 epoch。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/surfacesvc"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// realSvc 让 fakeHost.SurfaceSave 走真实 surfacesvc(校验/守卫行为与生产一致)。
|
||||
var realSvc *surfacesvc.Service
|
||||
|
||||
func newRealSvc(t *testing.T) {
|
||||
t.Helper()
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
realSvc = surfacesvc.New(st, nil)
|
||||
if _, err := realSvc.SetActivation(context.Background(), "alice", PluginID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeHost struct {
|
||||
extension.Host // 未覆盖方法仅测试未触达
|
||||
enabled bool
|
||||
epoch int64
|
||||
saved []extension.SurfaceSaveRequest
|
||||
artifacts map[string]bool
|
||||
}
|
||||
|
||||
func (h *fakeHost) SurfaceActivation(ctx context.Context) (bool, int64, error) {
|
||||
return h.enabled, h.epoch, nil
|
||||
}
|
||||
|
||||
func (h *fakeHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRequest) (core.SurfaceSnapshot, error) {
|
||||
h.saved = append(h.saved, req)
|
||||
return realSvc.Submit(ctx, "alice", PluginID, surfacesvc.SubmitRequest{
|
||||
SurfaceID: req.SurfaceID, ConversationID: req.ConversationID,
|
||||
BaseRevision: req.BaseRevision, ObservedEpoch: req.ObservedEpoch,
|
||||
SemanticText: req.SemanticText, Spec: req.Spec,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *fakeHost) SurfaceGet(ctx context.Context, surfaceID core.ID) (core.SurfaceSnapshot, error) {
|
||||
return realSvc.Get(ctx, "alice", surfaceID)
|
||||
}
|
||||
|
||||
func (h *fakeHost) SurfaceList(ctx context.Context, conversationID core.ID) ([]core.SurfaceMeta, error) {
|
||||
return realSvc.List(ctx, "alice", conversationID)
|
||||
}
|
||||
|
||||
func (h *fakeHost) ArtifactGet(id string) (core.Artifact, error) {
|
||||
if h.artifacts[id] {
|
||||
return core.Artifact{ID: core.ID(id)}, nil
|
||||
}
|
||||
return core.Artifact{}, store.ErrNotFound
|
||||
}
|
||||
|
||||
func validSpec() map[string]any {
|
||||
return map[string]any{
|
||||
"title": "选型对比",
|
||||
"nodes": []any{
|
||||
map[string]any{"type": "section", "id": "n1", "title": "概览", "children": []any{
|
||||
map[string]any{"type": "markdown", "id": "n1a", "source": []any{"doc:a"}, "markdown": "结论先行。"},
|
||||
}},
|
||||
map[string]any{"type": "table", "id": "n2", "table": map[string]any{
|
||||
"columns": []any{map[string]any{"key": "k", "label": "维度"}},
|
||||
"rows": []any{map[string]any{"cells": []any{[]any{"k", "价格"}}}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func reqWith(spec map[string]any, extra ...map[string]any) extension.Request {
|
||||
params := map[string]any{"spec": spec}
|
||||
for _, e := range extra {
|
||||
for k, v := range e {
|
||||
params[k] = v
|
||||
}
|
||||
}
|
||||
return extension.Request{ID: "r1", Params: params, Owner: "alice", ConversationID: "c1"}
|
||||
}
|
||||
|
||||
func TestPresentGuards(t *testing.T) {
|
||||
newRealSvc(t)
|
||||
ctx := context.Background()
|
||||
// 能力缺失(Host 未实现 SurfaceHost)→ 明确报错
|
||||
if _, err := present(ctx, plainHost{}, reqWith(validSpec())); err == nil {
|
||||
t.Fatal("capability-missing host should fail explicitly")
|
||||
}
|
||||
// 未启用 → 拒绝
|
||||
h := &fakeHost{enabled: false, artifacts: map[string]bool{}}
|
||||
if _, err := present(ctx, h, reqWith(validSpec())); err == nil {
|
||||
t.Fatal("disabled plugin should reject submit")
|
||||
}
|
||||
// 伪造 doc-ref:工件不存在 → 字段级工具错误
|
||||
h = &fakeHost{enabled: true, epoch: 1, artifacts: map[string]bool{}}
|
||||
spec := map[string]any{"title": "t", "nodes": []any{map[string]any{"type": "doc-ref", "docId": "art_ghost"}}}
|
||||
res, err := present(ctx, h, reqWith(spec))
|
||||
if err != nil {
|
||||
t.Fatalf("doc-ref violation should be tool-error, got %v", err)
|
||||
}
|
||||
body, _ := json.Marshal(res)
|
||||
if !strings.Contains(string(body), "art_ghost") {
|
||||
t.Fatalf("field errors should name the bad docId: %s", body)
|
||||
}
|
||||
// 目录违规(未知字段 color)→ 字段级清单
|
||||
bad := map[string]any{"title": "x", "nodes": []any{map[string]any{"type": "list", "items": []any{"a"}, "color": "red"}}}
|
||||
res, err = present(ctx, h, reqWith(bad))
|
||||
if err != nil {
|
||||
t.Fatalf("invalid spec should be tool-error, got %v", err)
|
||||
}
|
||||
te, ok := res.(toolErrorBody)
|
||||
if !ok || te.OK || len(te.Details) == 0 {
|
||||
t.Fatalf("expected field-level details, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresentSubmitCarriesEpochAndRevises(t *testing.T) {
|
||||
newRealSvc(t)
|
||||
ctx := context.Background()
|
||||
h := &fakeHost{enabled: true, epoch: 1, artifacts: map[string]bool{}}
|
||||
res, err := present(ctx, h, reqWith(validSpec()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, _ := res.(map[string]any)
|
||||
if m["ok"] != true || fmt.Sprint(m["revision"]) != "1" {
|
||||
t.Fatalf("submit result: %+v", m)
|
||||
}
|
||||
if len(h.saved) != 1 || h.saved[0].ObservedEpoch != 1 {
|
||||
t.Fatalf("submit must carry observed epoch: %+v", h.saved)
|
||||
}
|
||||
// 修订:带 surfaceId + baseRevision → revision 2
|
||||
res2, err := present(ctx, h, reqWith(validSpec(), map[string]any{
|
||||
"surfaceId": m["surfaceId"], "baseRevision": float64(1)}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m2, _ := res2.(map[string]any)
|
||||
if fmt.Sprint(m2["revision"]) != "2" {
|
||||
t.Fatalf("revision should advance to 2: %+v", m2)
|
||||
}
|
||||
// 禁用后:执行端拒绝(闸外第二道守卫)
|
||||
h.enabled = false
|
||||
if _, err := present(ctx, h, reqWith(validSpec())); err == nil {
|
||||
t.Fatal("post-disable submit should reject")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterSpecShape(t *testing.T) {
|
||||
var captured []json.RawMessage
|
||||
reg := &fakeRegistry{captureList: &captured}
|
||||
if err := register(reg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byID := map[string]json.RawMessage{}
|
||||
for _, raw := range captured {
|
||||
var id struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byID[id.ID] = raw
|
||||
}
|
||||
var spec struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description"`
|
||||
ExposeAsTool map[string]any `json:"exposeAsTool"`
|
||||
}
|
||||
if raw, ok := byID[CommandPresent]; !ok {
|
||||
t.Fatalf("present command not registered: %v", captured)
|
||||
} else if err := json.Unmarshal(raw, &spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec.ID != CommandPresent || spec.ExposeAsTool == nil {
|
||||
t.Fatalf("present must expose as tool: %s", byID[CommandPresent])
|
||||
}
|
||||
for _, need := range []string{"section{", "compare{", "steps{", "≤200 节点", "generative-ui.submit", "builtin.help", "system.intent"} {
|
||||
if !strings.Contains(spec.Description, need) {
|
||||
t.Fatalf("catalog incomplete: missing %q", need)
|
||||
}
|
||||
}
|
||||
var submit struct {
|
||||
ExposeAsTool map[string]any `json:"exposeAsTool"`
|
||||
}
|
||||
if raw, ok := byID[CommandSubmit]; !ok {
|
||||
t.Fatalf("submit command not registered: %v", captured)
|
||||
} else {
|
||||
_ = json.Unmarshal(raw, &submit)
|
||||
if submit.ExposeAsTool != nil {
|
||||
t.Fatal("submit must NOT expose as tool (button-only; model cannot call)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type plainHost struct{ extension.Host }
|
||||
|
||||
type fakeRegistry struct{ captureList *[]json.RawMessage }
|
||||
|
||||
func (r *fakeRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error {
|
||||
*r.captureList = append(*r.captureList, spec)
|
||||
return nil
|
||||
}
|
||||
func (r *fakeRegistry) AddSkill(name, markdown string) error { return nil }
|
||||
func (r *fakeRegistry) AddCommand(spec json.RawMessage) error { return nil }
|
||||
func (r *fakeRegistry) Note(kind, name string, p json.RawMessage) error { return nil }
|
||||
func (r *fakeRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthorizer) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRegistry) RegisterResourceAuthorizerV2(fn extension.ResourceAuthorizerV2) error {
|
||||
return nil
|
||||
}
|
||||
func (r *fakeRegistry) AddIntent(spec json.RawMessage) error { return nil }
|
||||
func (r *fakeRegistry) AddAdminZone(spec json.RawMessage) error { return nil }
|
||||
|
||||
func TestPresentRejectsUnknownActionIDs(t *testing.T) {
|
||||
newRealSvc(t)
|
||||
ctx := context.Background()
|
||||
h := &fakeHost{enabled: true, epoch: 1, artifacts: map[string]bool{}}
|
||||
spec := map[string]any{"title": "t", "nodes": []any{map[string]any{
|
||||
"type": "actions", "id": "a1",
|
||||
"actions": []any{
|
||||
map[string]any{"id": "shell.exec", "label": "危险"},
|
||||
map[string]any{"id": "generative-ui.submit", "label": "提交"},
|
||||
}}}}
|
||||
res, err := present(ctx, h, reqWith(spec))
|
||||
if err != nil {
|
||||
t.Fatalf("should be tool error, got %v", err)
|
||||
}
|
||||
te, ok := res.(toolErrorBody)
|
||||
if !ok || te.OK || len(te.Details) == 0 {
|
||||
t.Fatalf("expected field errors, got %+v", res)
|
||||
}
|
||||
if !strings.Contains(strings.Join(te.Details, ";"), "shell.exec") {
|
||||
t.Fatalf("errors should name the unknown action: %+v", te)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateActionIDsAllowsBClassHelpAndIntent(t *testing.T) {
|
||||
raw, _ := json.Marshal(map[string]any{"title": "t", "nodes": []any{map[string]any{
|
||||
"type": "actions", "id": "a1",
|
||||
"actions": []any{
|
||||
map[string]any{"id": ActionHelp, "label": "帮助"},
|
||||
map[string]any{"id": ActionIntent, "label": "意图"},
|
||||
map[string]any{"id": CommandSubmit, "label": "提交"},
|
||||
}}}})
|
||||
if errs := ValidateActionIDs(raw); len(errs) != 0 {
|
||||
t.Fatalf("B-class help/intent + submit should pass, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitFormStoresLedger(t *testing.T) {
|
||||
// C 类:表单数据落插件 KV 台账(键含 requestId 天然幂等)。
|
||||
var kv map[string]string = map[string]string{}
|
||||
h := &ledgerHost{kv: kv}
|
||||
res, err := submitForm(context.Background(), h, extension.Request{Params: map[string]any{
|
||||
"surfaceId": "surf_x", "requestId": "req-1", "revision": float64(1),
|
||||
"data": map[string]any{"years": "5"},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, _ := res.(map[string]any)
|
||||
if m["ok"] != true || m["stored"] != true {
|
||||
t.Fatalf("submit result: %+v", m)
|
||||
}
|
||||
if v, ok := kv["form:surf_x:req-1"]; !ok || !strings.Contains(v, `"years":"5"`) {
|
||||
t.Fatalf("ledger missing data: %v", kv)
|
||||
}
|
||||
// 幂等重放同键覆盖同值
|
||||
if _, err := submitForm(context.Background(), h, extension.Request{Params: map[string]any{
|
||||
"surfaceId": "surf_x", "requestId": "req-1", "data": map[string]any{"years": "5"},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
type ledgerHost struct {
|
||||
extension.Host
|
||||
kv map[string]string
|
||||
}
|
||||
|
||||
func (h *ledgerHost) KVPut(key, value string) error { h.kv[key] = value; return nil }
|
||||
func (h *ledgerHost) Audit(action, target, evidence string) error { return nil }
|
||||
|
||||
func TestNormalizeChildrenShape(t *testing.T) {
|
||||
// 真实偏差(2026-10-11 dev 实测):模型把 children 写成按 key 编号的对象映射。
|
||||
in := map[string]any{
|
||||
"type": "section", "id": "n1", "title": "结论",
|
||||
"children": map[string]any{
|
||||
"item": map[string]any{"type": "markdown", "id": "m1", "markdown": "结论先行"},
|
||||
},
|
||||
}
|
||||
out := normalizeSpecParam(map[string]any{"title": "t", "nodes": []any{in}})
|
||||
nodes := out.(map[string]any)["nodes"].([]any)
|
||||
ch := nodes[0].(map[string]any)["children"].([]any)
|
||||
if len(ch) != 1 || ch[0].(map[string]any)["id"] != "m1" {
|
||||
t.Fatalf("children should be repaired to array, got %v", ch)
|
||||
}
|
||||
}
|
||||
|
||||
// 真实偏差(2026-10-12 dev 实测,事件 seq 1395/2171):MiniMax-M3 对数组字段输出
|
||||
// XML-RPC 风格 {"item": …} 包装——kv/cells/items 整字段被包一层 {"item":[…]},
|
||||
// 数组元素再被包 {"item":["k","v"]}/{"item":"v"};表格行 cells 另有与列等长的
|
||||
// 裸标量数组形态。修复层须解包并按列 key 配对,present 才能落库。
|
||||
func TestPresentRepairsItemWrappers(t *testing.T) {
|
||||
newRealSvc(t)
|
||||
h := &fakeHost{enabled: true, epoch: 1}
|
||||
spec := map[string]any{
|
||||
"title": "本月支出结构分析(示例数据演示)",
|
||||
"nodes": []any{
|
||||
map[string]any{"type": "markdown", "id": "disclaimer", "markdown": "示例数据,非真实账单。"},
|
||||
map[string]any{"type": "kv", "id": "kv1", "kv": map[string]any{"item": []any{
|
||||
map[string]any{"item": []any{"月份", "2026-10"}},
|
||||
map[string]any{"item": []any{"总支出", "¥10,000"}},
|
||||
}}},
|
||||
map[string]any{"type": "list", "id": "l1", "items": map[string]any{"item": []any{
|
||||
map[string]any{"item": "餐饮 35%"},
|
||||
map[string]any{"item": "住房 25%"},
|
||||
}}},
|
||||
map[string]any{"type": "table", "id": "t1", "table": map[string]any{
|
||||
"columns": []any{
|
||||
map[string]any{"key": "cat", "label": "分类"},
|
||||
map[string]any{"key": "amt", "label": "金额"},
|
||||
map[string]any{"key": "pct", "label": "占比"},
|
||||
},
|
||||
"rows": []any{
|
||||
map[string]any{"cells": map[string]any{"item": []any{
|
||||
map[string]any{"item": "餐饮"}, map[string]any{"item": "¥3,500"}, map[string]any{"item": "35%"},
|
||||
}}},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
res, err := present(context.Background(), h, reqWith(spec))
|
||||
if err != nil {
|
||||
t.Fatalf("present: %v", err)
|
||||
}
|
||||
if body, ok := res.(toolErrorBody); ok {
|
||||
t.Fatalf("present should repair item wrappers, got %s %v", body.Error, body.Details)
|
||||
}
|
||||
if len(h.saved) != 1 {
|
||||
t.Fatalf("expected one saved surface, got %d", len(h.saved))
|
||||
}
|
||||
var saved core.SurfaceSpec
|
||||
if err := json.Unmarshal(h.saved[0].Spec, &saved); err != nil {
|
||||
t.Fatalf("saved spec should unmarshal: %v", err)
|
||||
}
|
||||
if saved.Nodes[1].KV[0] != [2]string{"月份", "2026-10"} || saved.Nodes[1].KV[1][1] != "¥10,000" {
|
||||
t.Fatalf("kv pairs should be unwrapped, got %v", saved.Nodes[1].KV)
|
||||
}
|
||||
if len(saved.Nodes[2].Items) != 2 || saved.Nodes[2].Items[0] != "餐饮 35%" {
|
||||
t.Fatalf("list items should be unwrapped, got %v", saved.Nodes[2].Items)
|
||||
}
|
||||
cells := saved.Nodes[3].Table.Rows[0].Cells
|
||||
if len(cells) != 3 || cells[0] != [2]string{"cat", "餐饮"} || cells[2][1] != "35%" {
|
||||
t.Fatalf("table cells should pair with column keys, got %v", cells)
|
||||
}
|
||||
}
|
||||
|
||||
// 长度不匹配的裸 cells 不做发明性配对:保留原样交校验器给字段级错误。
|
||||
func TestRepairTableCellsLeavesMismatchedShapes(t *testing.T) {
|
||||
in := map[string]any{"type": "table", "id": "t1", "table": map[string]any{
|
||||
"columns": []any{map[string]any{"key": "a"}, map[string]any{"key": "b"}},
|
||||
"rows": []any{map[string]any{"cells": []any{"x", "y", "z"}}},
|
||||
}}
|
||||
out := normalizeSpecParam(map[string]any{"title": "t", "nodes": []any{in}})
|
||||
rows := out.(map[string]any)["nodes"].([]any)[0].(map[string]any)["table"].(map[string]any)["rows"].([]any)
|
||||
got := rows[0].(map[string]any)["cells"].([]any)
|
||||
if len(got) != 3 || got[0] != "x" {
|
||||
t.Fatalf("mismatched scalars should stay untouched, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package genui
|
||||
|
||||
// U3 完成条件回放(真实模型,env 门控):任务→规格→保存→(语义投影回灌)→
|
||||
// 继续修改→新修订。跑生产代码路径:present 处理器 + surfacesvc + store + ctxasm
|
||||
// 投影(AssembleOptions.Surfaces)。模型 MiniMax-M3(2026-10-11 裁定)。
|
||||
//
|
||||
// 启用:GENUI_MINIMAX_API_KEY=<key> go test ./internal/genui/ -run TestRealModelSurfaceLoop -v -count=1
|
||||
// 未设 env 时跳过(CI 无凭据;本地收据经 make evidence 记录)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/ctxasm"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/surfacesvc"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
const materials = `材料:
|
||||
【doc:m1】甲方案:报价 ¥8/人/天;私有化可选;近半年发布 6 次,平均修复 2 天。
|
||||
【doc:m2】乙方案:报价 ¥12/人/天;仅 SaaS;万行表格场景无官方基准;升级断层(近两季仅小版本)。
|
||||
【doc:m3】尽调备忘:合规缺口(高)——数据出境条款未覆盖;培训成本(低)——术语不一致。`
|
||||
|
||||
type chatResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
ToolCalls []struct {
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
func callModelWithRepair(t *testing.T, ctx context.Context, system, user string, try func(map[string]any) (any, error)) (map[string]any, any, chatResp) {
|
||||
t.Helper()
|
||||
var usage chatResp
|
||||
params, r := callModel(t, ctx, system, user)
|
||||
usage.Usage.PromptTokens += r.Usage.PromptTokens
|
||||
usage.Usage.CompletionTokens += r.Usage.CompletionTokens
|
||||
res, err := try(params)
|
||||
if err == nil {
|
||||
if te, ok := res.(toolErrorBody); !ok || te.OK {
|
||||
return params, res, usage
|
||||
} else {
|
||||
res = te
|
||||
}
|
||||
} else if res == nil {
|
||||
t.Fatalf("dispatch error: %v", err)
|
||||
}
|
||||
// 修复轮:字段级错误回传,模型重提一次(KTD2)。
|
||||
fixUser := user + "\n\n上一次提交被拒绝:" + fmt.Sprint(res) + "\n请修正后重新调用工具。"
|
||||
params2, r2b := callModel(t, ctx, system, fixUser)
|
||||
usage.Usage.PromptTokens += r2b.Usage.PromptTokens
|
||||
usage.Usage.CompletionTokens += r2b.Usage.CompletionTokens
|
||||
res2, err2 := try(params2)
|
||||
if err2 != nil {
|
||||
t.Fatalf("repair dispatch error: %v", err2)
|
||||
}
|
||||
if te, ok := res2.(toolErrorBody); ok && !te.OK {
|
||||
t.Fatalf("repair still invalid: %+v", te)
|
||||
}
|
||||
return params2, res2, usage
|
||||
}
|
||||
|
||||
func callModel(t *testing.T, ctx context.Context, system, user string) (map[string]any, chatResp) {
|
||||
t.Helper()
|
||||
key := os.Getenv("GENUI_MINIMAX_API_KEY")
|
||||
tool := map[string]any{"type": "function", "function": map[string]any{
|
||||
"name": CommandPresent, "description": Catalog, "parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"spec": map[string]any{"type": "object"},
|
||||
"surfaceId": map[string]any{"type": "string"},
|
||||
"baseRevision": map[string]any{"type": "integer"},
|
||||
"summary": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []string{"spec"},
|
||||
}}}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"model": "MiniMax-M3", "temperature": 0.3, "max_tokens": 14000,
|
||||
"tools": []any{tool}, "tool_choice": map[string]any{"type": "function", "function": map[string]any{"name": CommandPresent}},
|
||||
"messages": []any{
|
||||
map[string]any{"role": "system", "content": system},
|
||||
map[string]any{"role": "user", "content": user},
|
||||
}})
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.minimaxi.com/v1/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
var out chatResp
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("model call: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(out.Choices) == 0 || len(out.Choices[0].Message.ToolCalls) == 0 {
|
||||
t.Fatalf("no tool call in response")
|
||||
}
|
||||
var params map[string]any
|
||||
if err := json.Unmarshal([]byte(out.Choices[0].Message.ToolCalls[0].Function.Arguments), ¶ms); err != nil {
|
||||
t.Fatalf("tool args: %v", err)
|
||||
}
|
||||
return params, out
|
||||
}
|
||||
|
||||
func TestRealModelSurfaceLoop(t *testing.T) {
|
||||
if os.Getenv("GENUI_MINIMAX_API_KEY") == "" {
|
||||
t.Skip("GENUI_MINIMAX_API_KEY not set; real-model replay runs locally with recorded receipts")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "replay.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
svc := surfacesvc.New(st, nil)
|
||||
if _, err := svc.SetActivation(ctx, "alice", PluginID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &realHost{svc: svc, artifacts: map[string]bool{}}
|
||||
|
||||
// 回合 1:任务→规格→保存
|
||||
sys1 := "你是本产品中的助手。用户需要一页比较视图时,用 generative-ui.present 提交页面。"
|
||||
try1 := func(p map[string]any) (any, error) {
|
||||
return present(ctx, h, extension.Request{ID: "t1", Params: p, Owner: "alice", ConversationID: "c1"})
|
||||
}
|
||||
_, res1, r1 := callModelWithRepair(t, ctx, sys1, materials+"\n\n目标:给决策会准备风险分析页(按严重度组织,标注来源)。", try1)
|
||||
m1, _ := res1.(map[string]any)
|
||||
if m1["ok"] != true {
|
||||
t.Fatalf("turn1 not accepted: %+v", res1)
|
||||
}
|
||||
snap1, err := svc.Get(ctx, "alice", core.ID(fmt.Sprint(m1["surfaceId"])))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("turn1: surface=%s revision=%d nodes=%d tokens=%d/%d",
|
||||
m1["surfaceId"], snap1.Revision, countNodes(snap1), r1.Usage.PromptTokens, r1.Usage.CompletionTokens)
|
||||
|
||||
// 回合 2:语义投影回灌 + 修订指令 → 新修订
|
||||
msgs2, _ := ctxasm.AssembleInput(core.AgentProfile{}, nil, nil,
|
||||
core.Message{Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "改为实施顺序"}}},
|
||||
nil, nil, nil, nil, nil, nil, ctxasm.AssembleOptions{Surfaces: []core.SurfaceSnapshot{snap1}})
|
||||
var sysParts []string
|
||||
for _, m := range msgs2 {
|
||||
if string(m.Role) == "system" {
|
||||
sysParts = append(sysParts, m.Content)
|
||||
}
|
||||
}
|
||||
sys2 := strings.Join(sysParts, "\n")
|
||||
try2 := func(p map[string]any) (any, error) {
|
||||
p["surfaceId"] = m1["surfaceId"]
|
||||
p["baseRevision"] = float64(snap1.Revision)
|
||||
return present(ctx, h, extension.Request{ID: "t2", Params: p, Owner: "alice", ConversationID: "c1"})
|
||||
}
|
||||
_, res2, r2 := callModelWithRepair(t, ctx, sys1+"\n"+sys2, materials+"\n\n目标:改为实施顺序(阶段与依赖),修订已有页面。", try2)
|
||||
m2, _ := res2.(map[string]any)
|
||||
if fmt.Sprint(m2["revision"]) != fmt.Sprint(snap1.Revision+1) {
|
||||
t.Fatalf("turn2 should revise to %d, got %+v", snap1.Revision+1, res2)
|
||||
}
|
||||
snap2, _ := svc.Get(ctx, "alice", core.ID(fmt.Sprint(m1["surfaceId"])))
|
||||
types2 := dominantTypes(snap2)
|
||||
same := len(types2) == len(snap1.Spec.Nodes)
|
||||
if same {
|
||||
for i := range types2 {
|
||||
if types2[i] != string(snap1.Spec.Nodes[i].Type) {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(snap2.Spec.Nodes) == 0 || (snap2.Spec.Title == snap1.Spec.Title && same) {
|
||||
t.Fatalf("turn2 structure did not change: %v", types2)
|
||||
}
|
||||
t.Logf("turn2: revision=%d title=%q types=%v tokens=%d/%d",
|
||||
snap2.Revision, snap2.Spec.Title, types2, r2.Usage.PromptTokens, r2.Usage.CompletionTokens)
|
||||
totalIn := r1.Usage.PromptTokens + r2.Usage.PromptTokens
|
||||
totalOut := r1.Usage.CompletionTokens + r2.Usage.CompletionTokens
|
||||
cost := float64(totalIn)/1e6*0.3*7.2 + float64(totalOut)/1e6*1.2*7.2
|
||||
t.Logf("cost: in=%d out=%d ≈¥%.3f (M3 $0.3/$1.2 per M, 7.2)", totalIn, totalOut, cost)
|
||||
}
|
||||
|
||||
type realHost struct {
|
||||
extension.Host
|
||||
svc *surfacesvc.Service
|
||||
artifacts map[string]bool
|
||||
}
|
||||
|
||||
func (h *realHost) SurfaceActivation(ctx context.Context) (bool, int64, error) {
|
||||
row, err := h.svc.Activation(ctx, "alice", PluginID)
|
||||
return row.Enabled, row.Epoch, err
|
||||
}
|
||||
func (h *realHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRequest) (core.SurfaceSnapshot, error) {
|
||||
return h.svc.Submit(ctx, "alice", PluginID, surfacesvc.SubmitRequest{
|
||||
SurfaceID: req.SurfaceID, ConversationID: req.ConversationID, BaseRevision: req.BaseRevision,
|
||||
ObservedEpoch: req.ObservedEpoch, SemanticText: req.SemanticText, Spec: req.Spec})
|
||||
}
|
||||
func (h *realHost) SurfaceGet(ctx context.Context, id core.ID) (core.SurfaceSnapshot, error) {
|
||||
return h.svc.Get(ctx, "alice", id)
|
||||
}
|
||||
func (h *realHost) SurfaceList(ctx context.Context, cid core.ID) ([]core.SurfaceMeta, error) {
|
||||
return h.svc.List(ctx, "alice", cid)
|
||||
}
|
||||
func (h *realHost) ArtifactGet(id string) (core.Artifact, error) {
|
||||
if h.artifacts[id] {
|
||||
return core.Artifact{ID: core.ID(id)}, nil
|
||||
}
|
||||
return core.Artifact{}, store.ErrNotFound
|
||||
}
|
||||
|
||||
func countNodes(s core.SurfaceSnapshot) int {
|
||||
var n int
|
||||
var walk func(u core.UINode)
|
||||
walk = func(u core.UINode) {
|
||||
n++
|
||||
for _, c := range u.Children {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
for _, x := range s.Spec.Nodes {
|
||||
walk(x)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func dominantTypes(s core.SurfaceSnapshot) []string {
|
||||
var out []string
|
||||
for _, n := range s.Spec.Nodes {
|
||||
out = append(out, string(n.Type))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package hostapi
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
||||
@@ -18,7 +18,10 @@ type Server struct {
|
||||
Store *store.Store
|
||||
Dispatch func(ctx context.Context, pluginID, owner, commandID string, params map[string]any) error
|
||||
Search func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error)
|
||||
Complete func(ctx context.Context, owner, prompt string) (string, error)
|
||||
// SharedSearch EXT-8 余项:非 nil 时 search 端点改用之(组合根传入
|
||||
// 「本人结果 + 适配器放行的共享追加」组合;nil=现状仅本人)。
|
||||
SharedSearch func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error)
|
||||
Complete func(ctx context.Context, owner, prompt string) (string, error)
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
@@ -143,7 +146,12 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "retriever unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
hits, err := s.Search(r.Context(), s.owner(r), r.URL.Query().Get("q"))
|
||||
// EXT-8 余项:共享版可用时整体切换(本人+共享追加)。
|
||||
fn := s.Search
|
||||
if s.SharedSearch != nil {
|
||||
fn = s.SharedSearch
|
||||
}
|
||||
hits, err := fn(r.Context(), s.owner(r), r.URL.Query().Get("q"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package hostapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// EXT-8 余项:hostapi 检索面——SharedSearch 可用时整体切换(本人+适配器放行的
|
||||
// 共享追加);不可用时维持现状(仅本人回调)。
|
||||
func TestSharedSearchFace(t *testing.T) {
|
||||
own := []core.RetrievalHit{{Doc: core.KnowledgeDoc{Owner: "bob", Chunk: "本人命中"}}}
|
||||
sharedHit := core.RetrievalHit{Doc: core.KnowledgeDoc{Owner: "alice", ProjectID: "prj-s", Chunk: "共享命中"}}
|
||||
|
||||
srv := &Server{Token: "t", Search: func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
|
||||
return own, nil
|
||||
}}
|
||||
run := func() int {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/host/v1/knowledge/search?q=x", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+srv.Token)
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
if code := run(); code != http.StatusOK {
|
||||
t.Fatalf("plain search: %d", code)
|
||||
}
|
||||
|
||||
srv.SharedSearch = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
|
||||
return append(own, sharedHit), nil
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/host/v1/knowledge/search?q=x", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+srv.Token)
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("shared search: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var hits []core.RetrievalHit
|
||||
if err := jsonUnmarshal(rec.Body.Bytes(), &hits); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) != 2 || hits[1].Doc.Owner != "alice" {
|
||||
t.Fatalf("hits = %+v, want 本人+共享", hits)
|
||||
}
|
||||
}
|
||||
+171
-60
@@ -4,7 +4,6 @@
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
@@ -34,11 +33,16 @@ func Parse(filename, mime string, content []byte) core.ParsedDocument {
|
||||
doc.Meta.Confidence = 1.0
|
||||
case "docx":
|
||||
doc.Meta.Parser = "builtin:docx"
|
||||
blocks, err := parseDocx(content)
|
||||
if err != nil {
|
||||
blocks, revised, err := parseDocxRevised(content)
|
||||
switch {
|
||||
case err != nil:
|
||||
doc.Warnings = append(doc.Warnings, "docx 解析失败:"+err.Error())
|
||||
doc.Meta.Confidence = 0.3
|
||||
} else {
|
||||
case revised:
|
||||
doc.Blocks = blocks
|
||||
doc.Warnings = append(doc.Warnings, docxRevisionWarning)
|
||||
doc.Meta.Confidence = 0.85
|
||||
default:
|
||||
doc.Blocks = blocks
|
||||
doc.Meta.Confidence = 0.92
|
||||
}
|
||||
@@ -52,6 +56,35 @@ func Parse(filename, mime string, content []byte) core.ParsedDocument {
|
||||
doc.Blocks = blocks
|
||||
doc.Meta.Confidence = 0.85
|
||||
}
|
||||
case "xlsx", "xlsm":
|
||||
doc.Meta.Parser = "builtin:xlsx"
|
||||
blocks, warns := parseXlsx(content)
|
||||
doc.Blocks = blocks
|
||||
doc.Warnings = append(doc.Warnings, warns...)
|
||||
switch {
|
||||
case len(blocks) == 0:
|
||||
doc.Meta.Confidence = 0.3
|
||||
case len(warns) > 0:
|
||||
doc.Meta.Confidence = 0.6 // 超限截断:内容可用但注明不完整
|
||||
default:
|
||||
doc.Meta.Confidence = 0.9
|
||||
}
|
||||
case "pptx", "pptm", "ppsx":
|
||||
doc.Meta.Parser = "builtin:pptx"
|
||||
blocks, warns := parsePptx(content)
|
||||
doc.Blocks = blocks
|
||||
doc.Warnings = append(doc.Warnings, warns...)
|
||||
switch {
|
||||
case len(blocks) == 0:
|
||||
doc.Meta.Confidence = 0.3
|
||||
case len(warns) > 0:
|
||||
doc.Meta.Confidence = 0.6
|
||||
default:
|
||||
doc.Meta.Confidence = 0.9
|
||||
}
|
||||
case "doc", "xls", "ppt":
|
||||
doc.Meta.Parser = "builtin:legacy-office"
|
||||
doc.Warnings = append(doc.Warnings, "旧版二进制格式 ."+ext+" 不解析:请转存为 ."+legacyOfficeTarget(ext)+" 后重传")
|
||||
default:
|
||||
if isImage(ext) {
|
||||
doc.Warnings = append(doc.Warnings, "图片不走文本解析(OCR 为 MinerU 深度档,远期)")
|
||||
@@ -133,22 +166,73 @@ func parsePlain(s string) []core.DocBlock {
|
||||
|
||||
// ---- docx(OOXML:zip 内 word/document.xml)----
|
||||
|
||||
// xpar 是解码后的段落。文字按文档顺序从任意深度的 w:t 收集,w:ins、
|
||||
// w:hyperlink、w:fldSimple、w:sdt 等包裹层内的文字因此不会丢;被删除或
|
||||
// 移走的原文(w:del、w:moveFrom)、域代码(w:instrText)与兼容回退分支
|
||||
// (mc:Fallback,与 mc:Choice 内容重复)不进正文。
|
||||
type xpar struct {
|
||||
Props struct {
|
||||
Style struct {
|
||||
Val string `xml:"val,attr"`
|
||||
} `xml:"pStyle"`
|
||||
} `xml:"pPr"`
|
||||
Runs []xrun `xml:"r"`
|
||||
Style string
|
||||
Text string
|
||||
Revised bool // 段内含修订标记(插入/删除/移动)
|
||||
}
|
||||
|
||||
type xrun struct {
|
||||
Text []struct {
|
||||
Content string `xml:",chardata"`
|
||||
} `xml:"t"`
|
||||
Break struct {
|
||||
Val string `xml:"val,attr"`
|
||||
} `xml:"br"`
|
||||
// docxSkipText 子树里的文字不属于当前正文。
|
||||
var docxSkipText = map[string]bool{
|
||||
"del": true, "moveFrom": true, "instrText": true, "delInstrText": true, "Fallback": true,
|
||||
}
|
||||
|
||||
// docxRevision 出现即说明文档带修订。
|
||||
var docxRevision = map[string]bool{"ins": true, "del": true, "moveFrom": true, "moveTo": true}
|
||||
|
||||
// UnmarshalXML 按 token 流遍历段落子树。按固定层级(p/r/t)映射只认 w:p
|
||||
// 的直接子级 w:r,修订插入、超链接与域结果里的文字会被静默跳过。
|
||||
func (p *xpar) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
var sb strings.Builder
|
||||
skip := 0
|
||||
for {
|
||||
tok, err := d.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
name := t.Name.Local
|
||||
if docxRevision[name] {
|
||||
p.Revised = true
|
||||
}
|
||||
switch {
|
||||
case docxSkipText[name]:
|
||||
skip++
|
||||
case skip > 0:
|
||||
case name == "pStyle" && p.Style == "":
|
||||
p.Style = attrAnyNS(t, "val")
|
||||
case name == "t":
|
||||
var s string
|
||||
if err := d.DecodeElement(&s, &t); err != nil {
|
||||
return err
|
||||
}
|
||||
sb.WriteString(s)
|
||||
}
|
||||
case xml.EndElement:
|
||||
if docxSkipText[t.Name.Local] && skip > 0 {
|
||||
skip--
|
||||
}
|
||||
if t.Name == start.Name {
|
||||
p.Text = sb.String()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// attrAnyNS 按本地名取属性值(w:val 与无前缀 val 都算)。
|
||||
func attrAnyNS(st xml.StartElement, name string) string {
|
||||
for _, a := range st.Attr {
|
||||
if a.Name.Local == name {
|
||||
return a.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type xtbl struct {
|
||||
@@ -164,97 +248,108 @@ type xtc struct {
|
||||
}
|
||||
|
||||
func parseDocx(content []byte) ([]core.DocBlock, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
|
||||
blocks, _, err := parseDocxRevised(content)
|
||||
return blocks, err
|
||||
}
|
||||
|
||||
// parseDocxRevised 同 parseDocx,另报告文档是否带修订标记。
|
||||
func parseDocxRevised(content []byte) ([]core.DocBlock, bool, error) {
|
||||
zr, err := openZip(content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open zip: %w", err)
|
||||
return nil, false, fmt.Errorf("open zip: %w", err)
|
||||
}
|
||||
var docXML []byte
|
||||
for _, f := range zr.File {
|
||||
if f.Name == "word/document.xml" {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
docXML, _ = io.ReadAll(rc)
|
||||
rc.Close()
|
||||
break
|
||||
}
|
||||
}
|
||||
if docXML == nil {
|
||||
return nil, fmt.Errorf("word/document.xml not found")
|
||||
docXML, err := zipPart(zr, "word/document.xml")
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return parseDocxBody(docXML)
|
||||
}
|
||||
|
||||
// docxRevisionWarning 文档带修订时的显式告警(SR-A5:不静默)。
|
||||
const docxRevisionWarning = "docx 含修订标记:正文按「接受全部修订」提取(插入保留、删除不计入);审阅修订过程请查看原件"
|
||||
|
||||
// parseDocxBody 按文档顺序解码 body 的段落与表格:token 流上遇到 p/tbl 就地
|
||||
// DecodeElement(整元素消费,单元格内嵌 p 不会重复入列),表格与前后段落保持原位。
|
||||
func parseDocxBody(docXML []byte) ([]core.DocBlock, error) {
|
||||
// 段落可能包在 w:ins、w:sdt 等容器里,所以任意深度的 p 与 tbl 都识别;
|
||||
// 返回值 revised 报告是否遇到修订标记。
|
||||
func parseDocxBody(docXML []byte) (blocks []core.DocBlock, revised bool, err error) {
|
||||
dec := xml.NewDecoder(bytes.NewReader(docXML))
|
||||
var blocks []core.DocBlock
|
||||
skip := 0
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode document.xml: %w", err)
|
||||
return nil, revised, fmt.Errorf("decode document.xml: %w", err)
|
||||
}
|
||||
if end, ok := tok.(xml.EndElement); ok {
|
||||
if docxSkipText[end.Name.Local] && skip > 0 {
|
||||
skip--
|
||||
}
|
||||
continue
|
||||
}
|
||||
start, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch start.Name.Local {
|
||||
name := start.Name.Local
|
||||
if docxRevision[name] {
|
||||
revised = true
|
||||
}
|
||||
if docxSkipText[name] {
|
||||
skip++
|
||||
continue
|
||||
}
|
||||
if skip > 0 {
|
||||
continue
|
||||
}
|
||||
switch name {
|
||||
case "p":
|
||||
var p xpar
|
||||
if err := dec.DecodeElement(&p, &start); err != nil {
|
||||
return nil, fmt.Errorf("decode paragraph: %w", err)
|
||||
return nil, revised, fmt.Errorf("decode paragraph: %w", err)
|
||||
}
|
||||
text := paragraphText(p)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
revised = revised || p.Revised
|
||||
if strings.TrimSpace(p.Text) == "" {
|
||||
continue
|
||||
}
|
||||
if lvl, ok := headingLevel(p.Props.Style.Val); ok {
|
||||
blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: lvl, Text: text})
|
||||
if lvl, ok := headingLevel(p.Style); ok {
|
||||
blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: lvl, Text: p.Text})
|
||||
} else {
|
||||
blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: text})
|
||||
blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: p.Text})
|
||||
}
|
||||
case "tbl":
|
||||
var tbl xtbl
|
||||
if err := dec.DecodeElement(&tbl, &start); err != nil {
|
||||
return nil, fmt.Errorf("decode table: %w", err)
|
||||
return nil, revised, fmt.Errorf("decode table: %w", err)
|
||||
}
|
||||
if rows := tableRows(tbl); len(rows) > 0 {
|
||||
rows, tblRevised := tableRows(tbl)
|
||||
revised = revised || tblRevised
|
||||
if len(rows) > 0 {
|
||||
blocks = append(blocks, core.DocBlock{Type: core.BlockTable, Rows: rows})
|
||||
}
|
||||
}
|
||||
}
|
||||
return blocks, nil
|
||||
return blocks, revised, nil
|
||||
}
|
||||
|
||||
func tableRows(tbl xtbl) [][]string {
|
||||
func tableRows(tbl xtbl) ([][]string, bool) {
|
||||
var rows [][]string
|
||||
revised := false
|
||||
for _, tr := range tbl.Rows {
|
||||
row := []string{}
|
||||
for _, tc := range tr.Cells {
|
||||
cell := []string{}
|
||||
for _, p := range tc.Paragraphs {
|
||||
cell = append(cell, paragraphText(p))
|
||||
revised = revised || p.Revised
|
||||
cell = append(cell, p.Text)
|
||||
}
|
||||
row = append(row, strings.Join(cell, " "))
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func paragraphText(p xpar) string {
|
||||
var sb strings.Builder
|
||||
for _, r := range p.Runs {
|
||||
for _, t := range r.Text {
|
||||
sb.WriteString(t.Content)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
return rows, revised
|
||||
}
|
||||
|
||||
func headingLevel(style string) (int, bool) {
|
||||
@@ -322,13 +417,29 @@ func isMostlyPrintable(s string) bool {
|
||||
return nonPrint*10 < len(s)
|
||||
}
|
||||
|
||||
// legacyOfficeTarget 旧版二进制 Office 格式的现代等价格式(提示转存用)。
|
||||
func legacyOfficeTarget(ext string) string {
|
||||
switch ext {
|
||||
case "doc":
|
||||
return "docx"
|
||||
case "xls":
|
||||
return "xlsx"
|
||||
case "ppt":
|
||||
return "pptx"
|
||||
}
|
||||
return ext
|
||||
}
|
||||
|
||||
// ParsersDescription 返回解析器注册表描述(来源标记;设置页/文档用)。
|
||||
func ParsersDescription() []core.ParserDesc {
|
||||
return []core.ParserDesc{
|
||||
{ID: "builtin:md", Formats: []string{"md", "markdown"}, Source: core.SourceBuiltin},
|
||||
{ID: "builtin:txt", Formats: []string{"txt", "csv", "log"}, Source: core.SourceBuiltin},
|
||||
{ID: "builtin:txt", Formats: []string{"txt", "text", "csv", "log"}, Source: core.SourceBuiltin},
|
||||
{ID: "builtin:docx", Formats: []string{"docx"}, Source: core.SourceBuiltin},
|
||||
{ID: "builtin:pdf", Formats: []string{"pdf"}, Source: core.SourceBuiltin, Note: "文本层;扫描件为远期 MinerU 深度档"},
|
||||
{ID: "builtin:xlsx", Formats: []string{"xlsx", "xlsm"}, Source: core.SourceBuiltin},
|
||||
{ID: "builtin:pptx", Formats: []string{"pptx", "pptm", "ppsx"}, Source: core.SourceBuiltin},
|
||||
{ID: "builtin:legacy-office", Formats: []string{"doc", "xls", "ppt"}, Source: core.SourceBuiltin, Note: "不解析;提示转存现代格式后重传"},
|
||||
{ID: "mineru", Formats: []string{"pdf", "png", "jpg"}, Source: core.SourcePlugin, Note: "远期可选:深度解析与交叉验证(默认不部署)"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,68 @@ func TestParseDocx(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// docxWith 把 body 片段包成最小 docx(zip 内仅 word/document.xml)。
|
||||
func docxWith(t *testing.T, body string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
f, err := zw.Create("word/document.xml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Write([]byte(`<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>` + body + `</w:body></w:document>`))
|
||||
zw.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// 修订与超链接:插入/超链接/域结果内的文字必须保留;删除文字不进正文;
|
||||
// 含修订时显式告警(SR-A5:禁止静默降级)。
|
||||
func TestParseDocxTrackedChangesAndHyperlinks(t *testing.T) {
|
||||
body := `<w:p><w:r><w:t>周期</w:t></w:r><w:ins w:id="1" w:author="a"><w:r><w:t>18</w:t></w:r></w:ins>` +
|
||||
`<w:del w:id="2" w:author="a"><w:r><w:delText>12</w:delText></w:r></w:del><w:r><w:t>个月</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>见</w:t></w:r><w:hyperlink r:id="rId5"><w:r><w:t>附件A</w:t></w:r></w:hyperlink></w:p>` +
|
||||
`<w:p><w:fldSimple w:instr="PAGE"><w:r><w:t>3</w:t></w:r></w:fldSimple><w:r><w:t>页</w:t></w:r></w:p>` +
|
||||
`<w:tbl><w:tr><w:tc><w:p><w:ins w:id="3" w:author="a"><w:r><w:t>新增项</w:t></w:r></w:ins></w:p></w:tc></w:tr></w:tbl>`
|
||||
doc := Parse("修订稿.docx", "", docxWith(t, body))
|
||||
var paras []string
|
||||
var cell string
|
||||
for _, b := range doc.Blocks {
|
||||
switch b.Type {
|
||||
case core.BlockPara:
|
||||
paras = append(paras, b.Text)
|
||||
case core.BlockTable:
|
||||
if len(b.Rows) > 0 && len(b.Rows[0]) > 0 {
|
||||
cell = b.Rows[0][0]
|
||||
}
|
||||
}
|
||||
}
|
||||
want := []string{"周期18个月", "见附件A", "3页"}
|
||||
if strings.Join(paras, "|") != strings.Join(want, "|") {
|
||||
t.Errorf("paragraphs: got %q want %q", paras, want)
|
||||
}
|
||||
if cell != "新增项" {
|
||||
t.Errorf("table cell inside w:ins: got %q", cell)
|
||||
}
|
||||
if strings.Contains(doc.PlainText(), "12个月") {
|
||||
t.Errorf("deleted text leaked into body: %q", doc.PlainText())
|
||||
}
|
||||
warned := false
|
||||
for _, w := range doc.Warnings {
|
||||
warned = warned || strings.Contains(w, "修订")
|
||||
}
|
||||
if !warned {
|
||||
t.Errorf("tracked changes must be reported in warnings, got %v", doc.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// 无修订的普通文档不产生修订告警(避免噪音)。
|
||||
func TestParseDocxNoRevisionNoWarning(t *testing.T) {
|
||||
doc := Parse("干净.docx", "", docxWith(t, `<w:p><w:r><w:t>正文</w:t></w:r></w:p>`))
|
||||
if len(doc.Warnings) != 0 {
|
||||
t.Errorf("clean docx warnings: %v", doc.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePDFInvalid(t *testing.T) {
|
||||
doc := Parse("scan.pdf", "application/pdf", []byte("not a pdf"))
|
||||
if len(doc.Warnings) == 0 || doc.Meta.Confidence > 0.5 {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package ingest
|
||||
|
||||
// OOXML 公共件:zip 部件读取(解压上限防 zip 炸弹)、关系部件解析、
|
||||
// r:id 属性与相对路径归一。docx/xlsx/pptx 解析器共用;纯标准库。
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ooxmlPartLimit 单个 zip 部件的解压上限(测试可覆盖)。
|
||||
var ooxmlPartLimit int64 = 64 << 20
|
||||
|
||||
func openZip(content []byte) (*zip.Reader, error) {
|
||||
return zip.NewReader(bytes.NewReader(content), int64(len(content)))
|
||||
}
|
||||
|
||||
// zipPart 按名称读取部件;解压超过 ooxmlPartLimit 视为异常拒绝。
|
||||
func zipPart(zr *zip.Reader, name string) ([]byte, error) {
|
||||
for _, f := range zr.File {
|
||||
if f.Name != name {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(rc, ooxmlPartLimit+1))
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(b)) > ooxmlPartLimit {
|
||||
return nil, fmt.Errorf("part %s exceeds %d bytes decompression limit", name, ooxmlPartLimit)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
return nil, fmt.Errorf("part %s not found", name)
|
||||
}
|
||||
|
||||
// relsPath 计算部件对应的关系部件名(xl/workbook.xml → xl/_rels/workbook.xml.rels)。
|
||||
func relsPath(part string) string {
|
||||
dir, base := path.Split(part)
|
||||
return dir + "_rels/" + base + ".rels"
|
||||
}
|
||||
|
||||
type ooxmlRel struct {
|
||||
ID, Target, Type string
|
||||
}
|
||||
|
||||
// parseRels 解析部件的关系表;无关系部件返回空表(不是错误)。
|
||||
func parseRels(zr *zip.Reader, part string) (map[string]ooxmlRel, error) {
|
||||
data, err := zipPart(zr, relsPath(part))
|
||||
if err != nil {
|
||||
return map[string]ooxmlRel{}, nil
|
||||
}
|
||||
dec := xml.NewDecoder(bytes.NewReader(data))
|
||||
out := map[string]ooxmlRel{}
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, ok := tok.(xml.StartElement)
|
||||
if !ok || st.Name.Local != "Relationship" {
|
||||
continue
|
||||
}
|
||||
r := ooxmlRel{}
|
||||
for _, a := range st.Attr {
|
||||
switch a.Name.Local {
|
||||
case "Id":
|
||||
r.ID = a.Value
|
||||
case "Target":
|
||||
r.Target = a.Value
|
||||
case "Type":
|
||||
r.Type = a.Value
|
||||
}
|
||||
}
|
||||
if r.ID != "" {
|
||||
out[r.ID] = r
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveRelTarget 把关系 target 解析为 zip 部件名:绝对 target(/xl/…)去
|
||||
// 前导斜杠;相对 target 相对 partDir;归一化 ../ 与 ./ 段。
|
||||
func resolveRelTarget(partDir, target string) string {
|
||||
t := target
|
||||
if !strings.HasPrefix(target, "/") {
|
||||
t = partDir + "/" + target
|
||||
}
|
||||
var seg []string
|
||||
for _, s := range strings.Split(t, "/") {
|
||||
switch s {
|
||||
case "", ".":
|
||||
case "..":
|
||||
if len(seg) > 0 {
|
||||
seg = seg[:len(seg)-1]
|
||||
}
|
||||
default:
|
||||
seg = append(seg, s)
|
||||
}
|
||||
}
|
||||
return strings.Join(seg, "/")
|
||||
}
|
||||
|
||||
// ridAttr 取 r:id:局部名 id 且带命名空间前缀(Transitional 与 Strict OOXML
|
||||
// 的关系引用都满足该形态;无前缀的 id 是别的编号,不误取)。
|
||||
func ridAttr(st xml.StartElement) string {
|
||||
for _, a := range st.Attr {
|
||||
if a.Name.Local == "id" && a.Name.Space != "" {
|
||||
return a.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// attrLocal 取无命名空间前缀的属性值。
|
||||
func attrLocal(st xml.StartElement, name string) string {
|
||||
for _, a := range st.Attr {
|
||||
if a.Name.Local == name && a.Name.Space == "" {
|
||||
return a.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// relIsType 判断关系类型是否以标准关系类型后缀结尾(/worksheet、/slide 等)。
|
||||
func relIsType(rel ooxmlRel, suffix string) bool {
|
||||
return strings.HasSuffix(rel.Type, suffix)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package ingest
|
||||
|
||||
// 真实生成器轮测:openpyxl / python-pptx 现场生成 xlsx/pptx 再解析,防止
|
||||
// 手工夹具只验证"自己写的 XML"。生成器缺失时 t.Skip(诚实出口);CI 已装
|
||||
// 这两个包(.gitea/workflows/ci.yaml 安装 python-docx python-pptx openpyxl)。
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func pythonModuleAvailable(t *testing.T, module string) bool {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("python3"); err != nil {
|
||||
return false
|
||||
}
|
||||
return exec.Command("python3", "-c", "import "+module).Run() == nil
|
||||
}
|
||||
|
||||
// runPythonScript 把脚本写到临时目录执行;失败即 t.Fatal(脚本属测试自身)。
|
||||
func runPythonScript(t *testing.T, script string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "gen.py")
|
||||
if err := os.WriteFile(path, []byte(script), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := exec.Command("python3", path).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("python generator failed: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXlsxFromOpenpyxl(t *testing.T) {
|
||||
if !pythonModuleAvailable(t, "openpyxl") {
|
||||
t.Skip("python3 openpyxl not available")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "报价单.xlsx")
|
||||
runPythonScript(t, `
|
||||
import openpyxl
|
||||
from datetime import datetime
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "报价"
|
||||
ws.append(["物料", "单价", "日期"])
|
||||
ws.append(["支架", 12.5, datetime(2024, 1, 1)])
|
||||
ws["C2"].number_format = "yyyy-mm-dd"
|
||||
ws["D2"] = "=A2&\"-x\""
|
||||
ws["D3"] = True
|
||||
wb.create_sheet("说明").append(["备注行"])
|
||||
wb.save(r"`+file+`")
|
||||
`)
|
||||
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc := Parse(file, "", data)
|
||||
if doc.Meta.Parser != "builtin:xlsx" || len(doc.Warnings) != 0 {
|
||||
t.Fatalf("meta: %+v warnings: %v", doc.Meta, doc.Warnings)
|
||||
}
|
||||
var heads []string
|
||||
for _, b := range doc.Blocks {
|
||||
if b.Type == core.BlockHeading {
|
||||
heads = append(heads, b.Text)
|
||||
}
|
||||
}
|
||||
if len(heads) != 2 || heads[0] != "报价" || heads[1] != "说明" {
|
||||
t.Fatalf("headings: %v", heads)
|
||||
}
|
||||
plain := doc.PlainText()
|
||||
for _, want := range []string{"物料", "支架", "12.5", "2024-01-01"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Errorf("missing %q in: %s", want, plain)
|
||||
}
|
||||
}
|
||||
// openpyxl 公式不带缓存值:回显公式本身(可检索、不冒充结果)。
|
||||
if !strings.Contains(plain, "=A2") {
|
||||
t.Errorf("formula without cache should echo: %s", plain)
|
||||
}
|
||||
if !strings.Contains(plain, "TRUE") {
|
||||
t.Errorf("boolean cell should render TRUE: %s", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePptxFromPythonPptx(t *testing.T) {
|
||||
if !pythonModuleAvailable(t, "pptx") {
|
||||
t.Skip("python3 pptx not available")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "方案评审.pptx")
|
||||
runPythonScript(t, `
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches
|
||||
prs = Presentation()
|
||||
s1 = prs.slides.add_slide(prs.slide_layouts[0])
|
||||
s1.shapes.title.text = "评审结论"
|
||||
s1.placeholders[1].text = "通过"
|
||||
s2 = prs.slides.add_slide(prs.slide_layouts[5])
|
||||
s2.shapes.title.text = "报价明细"
|
||||
tbl = s2.shapes.add_table(2, 2, Inches(1), Inches(2), Inches(6), Inches(1)).table
|
||||
tbl.cell(0, 0).text = "物料"
|
||||
tbl.cell(0, 1).text = "单价"
|
||||
tbl.cell(1, 0).text = "支架"
|
||||
tbl.cell(1, 1).text = "12.5"
|
||||
notes = s1.notes_slide.notes_text_frame
|
||||
notes.text = "需要补充法规依据"
|
||||
prs.save(r"`+file+`")
|
||||
`)
|
||||
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc := Parse(file, "", data)
|
||||
if doc.Meta.Parser != "builtin:pptx" || len(doc.Warnings) != 0 {
|
||||
t.Fatalf("meta: %+v warnings: %v", doc.Meta, doc.Warnings)
|
||||
}
|
||||
var heads []string
|
||||
for _, b := range doc.Blocks {
|
||||
if b.Type == core.BlockHeading {
|
||||
heads = append(heads, b.Text)
|
||||
}
|
||||
}
|
||||
if len(heads) != 2 || !strings.HasPrefix(heads[0], "幻灯片 1:评审结论") || !strings.HasPrefix(heads[1], "幻灯片 2:报价明细") {
|
||||
t.Fatalf("headings: %v", heads)
|
||||
}
|
||||
plain := doc.PlainText()
|
||||
for _, want := range []string{"通过", "物料", "支架", "12.5", "备注:需要补充法规依据"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Errorf("missing %q in: %s", want, plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package ingest
|
||||
|
||||
// OOXML(xlsx/pptx)解析器测试:手工构造最小 zip 部件,覆盖顺序解析、富文本、
|
||||
// 公式、日期、稀疏列、隐藏表、组合形状、AlternateContent、表格合并、备注、
|
||||
// 上限与失败路径。真实生成器的轮测见 ooxml_python_test.go。
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// zipBytes 按名称构造 zip(键排序保证确定性)。
|
||||
func zipBytes(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
names := make([]string, 0, len(files))
|
||||
for n := range files {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
w, err := zw.Create(n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write([]byte(files[n])); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
const relTypeNS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
|
||||
func xlsxFixture() map[string]string {
|
||||
return map[string]string{
|
||||
"xl/workbook.xml": `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="` + relTypeNS + `">
|
||||
<sheets>
|
||||
<sheet name="报价" sheetId="1" r:id="rId1"/>
|
||||
<sheet name="草稿" sheetId="2" state="hidden" r:id="rId2"/>
|
||||
</sheets></workbook>`,
|
||||
"xl/_rels/workbook.xml.rels": `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="` + relTypeNS + `/worksheet" Target="worksheets/sheet1.xml"/>
|
||||
<Relationship Id="rId2" Type="` + relTypeNS + `/worksheet" Target="/xl/worksheets/sheet2.xml"/>
|
||||
</Relationships>`,
|
||||
// shared[0]=物料;shared[1]=富文本"单价"(rPh 注音必须排除)。
|
||||
"xl/sharedStrings.xml": `<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<si><t>物料</t></si>
|
||||
<si><rPh sb="0" eb="2"><t>たんか</t></rPh><r><rPr lang="ja"/><t>单价</t></r></si>
|
||||
</sst>`,
|
||||
"xl/styles.xml": `<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<numFmts><numFmt numFmtId="164" formatCode="yyyy"年"m"月"d"日""/></numFmts>
|
||||
<cellXfs count="3">
|
||||
<xf numFmtId="0"/>
|
||||
<xf numFmtId="14" applyNumberFormat="1"/>
|
||||
<xf numFmtId="164" applyNumberFormat="1"/>
|
||||
</cellXfs></styleSheet>`,
|
||||
// A1=物料 B1=单价(富文本) A2=公式缓存 10 B2=无缓存公式 C2=日期(内置14)=2024-01-01
|
||||
// D2=TRUE E2=#DIV/0! F2=内联 G2=自定义日期格式(2024-01-02) 稀疏行 C4=物料。
|
||||
"xl/worksheets/sheet1.xml": `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<sheetData>
|
||||
<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1" t="s"><v>1</v></c></row>
|
||||
<row r="2"><c r="A2"><f>B9*2</f><v>10</v></c><c r="B2"><f>B9*3</f></c><c r="C2" s="1"><v>45292</v></c><c r="D2" t="b"><v>1</v></c><c r="E2" t="e"><v>#DIV/0!</v></c><c r="F2" t="inlineStr"><is><t>内联</t></is></c><c r="G2" s="2"><v>45293</v></c></row>
|
||||
<row r="4"><c r="C4" t="s"><v>0</v></c></row>
|
||||
</sheetData></worksheet>`,
|
||||
"xl/worksheets/sheet2.xml": `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<sheetData><row r="1"><c r="A1"><v>7</v></c></row></sheetData></worksheet>`,
|
||||
}
|
||||
}
|
||||
|
||||
func findTable(doc core.ParsedDocument, after int) *core.DocBlock {
|
||||
for i := after; i < len(doc.Blocks); i++ {
|
||||
if doc.Blocks[i].Type == core.BlockTable {
|
||||
return &doc.Blocks[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestParseXlsxBuiltFromParts(t *testing.T) {
|
||||
doc := Parse("报价单.xlsx", "", zipBytes(t, xlsxFixture()))
|
||||
if doc.Meta.Parser != "builtin:xlsx" || len(doc.Warnings) != 0 {
|
||||
t.Fatalf("meta: %+v warnings: %v", doc.Meta, doc.Warnings)
|
||||
}
|
||||
if doc.Meta.Confidence != 0.9 {
|
||||
t.Fatalf("confidence: %v", doc.Meta.Confidence)
|
||||
}
|
||||
// 工作表顺序取 workbook.xml(报价在前),隐藏表标注;绝对 target /xl/… 也能定位。
|
||||
if doc.Blocks[0].Type != core.BlockHeading || doc.Blocks[0].Text != "报价" {
|
||||
t.Fatalf("first block: %+v", doc.Blocks[0])
|
||||
}
|
||||
heads := []string{}
|
||||
for _, b := range doc.Blocks {
|
||||
if b.Type == core.BlockHeading {
|
||||
heads = append(heads, b.Text)
|
||||
}
|
||||
}
|
||||
if strings.Join(heads, "|") != "报价|草稿(隐藏)" {
|
||||
t.Fatalf("headings: %v", heads)
|
||||
}
|
||||
tbl := findTable(doc, 0)
|
||||
if tbl == nil || len(tbl.Rows) != 3 {
|
||||
t.Fatalf("table: %+v", tbl)
|
||||
}
|
||||
row1, row2, row4 := tbl.Rows[0], tbl.Rows[1], tbl.Rows[2]
|
||||
if row1[0] != "物料" || row1[1] != "单价" {
|
||||
t.Errorf("header row (phonetic must be excluded): %v", row1)
|
||||
}
|
||||
want := []string{"10", "=B9*3", "2024-01-01", "TRUE", "#DIV/0!", "内联", "2024-01-02"}
|
||||
if len(row2) != len(want) {
|
||||
t.Fatalf("row2: %v", row2)
|
||||
}
|
||||
for i, w := range want {
|
||||
if row2[i] != w {
|
||||
t.Errorf("row2[%d]: got %q want %q", i, row2[i], w)
|
||||
}
|
||||
}
|
||||
// 稀疏单元格按引用就位:C4 → 第三列。
|
||||
if len(row4) != 3 || row4[0] != "" || row4[1] != "" || row4[2] != "物料" {
|
||||
t.Errorf("sparse row: %v", row4)
|
||||
}
|
||||
if !strings.Contains(doc.PlainText(), "2024-01-01") {
|
||||
t.Error("PlainText missing date text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXlsxEmptySheetSkipped(t *testing.T) {
|
||||
files := xlsxFixture()
|
||||
files["xl/worksheets/sheet2.xml"] = `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="1"/></sheetData></worksheet>`
|
||||
doc := Parse("x.xlsx", "", zipBytes(t, files))
|
||||
heads := 0
|
||||
for _, b := range doc.Blocks {
|
||||
if b.Type == core.BlockHeading {
|
||||
heads++
|
||||
}
|
||||
}
|
||||
if heads != 1 || len(doc.Warnings) != 0 {
|
||||
t.Fatalf("empty sheet must be skipped: blocks=%+v warnings=%v", doc.Blocks, doc.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXlsxInvalid(t *testing.T) {
|
||||
doc := Parse("坏.xlsx", "", []byte("not a zip"))
|
||||
if len(doc.Warnings) == 0 || len(doc.Blocks) != 0 || doc.Meta.Confidence != 0.3 {
|
||||
t.Fatalf("invalid xlsx should fail visibly: %+v %v", doc.Meta, doc.Warnings)
|
||||
}
|
||||
files := xlsxFixture()
|
||||
delete(files, "xl/workbook.xml")
|
||||
doc = Parse("无工作簿.xlsx", "", zipBytes(t, files))
|
||||
if len(doc.Warnings) == 0 || len(doc.Blocks) != 0 {
|
||||
t.Fatalf("missing workbook.xml should fail visibly: %v", doc.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXlsxCellCap(t *testing.T) {
|
||||
old := xlsxMaxCells
|
||||
xlsxMaxCells = 3
|
||||
defer func() { xlsxMaxCells = old }()
|
||||
doc := Parse("超限.xlsx", "", zipBytes(t, xlsxFixture()))
|
||||
if len(doc.Blocks) == 0 {
|
||||
t.Fatal("truncated parse should keep partial blocks")
|
||||
}
|
||||
if len(doc.Warnings) == 0 || doc.Meta.Confidence != 0.6 {
|
||||
t.Fatalf("truncation must warn and lower confidence: %v %v", doc.Meta.Confidence, doc.Warnings)
|
||||
}
|
||||
if !strings.Contains(strings.Join(doc.Warnings, ";"), "上限") {
|
||||
t.Errorf("warnings: %v", doc.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXlsxPartSizeCap(t *testing.T) {
|
||||
old := ooxmlPartLimit
|
||||
ooxmlPartLimit = 16
|
||||
defer func() { ooxmlPartLimit = old }()
|
||||
doc := Parse("炸弹.xlsx", "", zipBytes(t, xlsxFixture()))
|
||||
if len(doc.Warnings) == 0 || len(doc.Blocks) != 0 || doc.Meta.Confidence != 0.3 {
|
||||
t.Fatalf("oversized part must fail visibly: %+v %v", doc.Meta, doc.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseXlsxDate1904(t *testing.T) {
|
||||
files := xlsxFixture()
|
||||
files["xl/workbook.xml"] = strings.Replace(files["xl/workbook.xml"],
|
||||
"<sheets>", `<workbookPr date1904="1"/><sheets>`, 1)
|
||||
files["xl/worksheets/sheet1.xml"] = `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||
<sheetData><row r="1"><c r="A1" s="1"><v>1</v></c><c r="B1" s="1"><v>0.520833333333333</v></c></row></sheetData></worksheet>`
|
||||
doc := Parse("mac.xlsx", "", zipBytes(t, files))
|
||||
tbl := findTable(doc, 0)
|
||||
// 1904 历法:serial 0 = 1904-01-01,serial 1 = 1904-01-02。
|
||||
if tbl == nil || tbl.Rows[0][0] != "1904-01-02" {
|
||||
t.Fatalf("1904 epoch: %+v", tbl)
|
||||
}
|
||||
if !strings.Contains(tbl.Rows[0][1], "1904-01-01 12:30") {
|
||||
t.Errorf("time fraction: %q", tbl.Rows[0][1])
|
||||
}
|
||||
}
|
||||
|
||||
const pNS = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
||||
const aNS = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
|
||||
func sp(phType, text string) string {
|
||||
ph := ""
|
||||
if phType != "" {
|
||||
ph = `<p:nvPr><p:ph type="` + phType + `"/></p:nvPr>`
|
||||
} else {
|
||||
ph = `<p:nvPr/>`
|
||||
}
|
||||
return `<p:sp><p:nvSpPr><p:cNvPr id="2" name="S"/><p:cNvSpPr/></p:nvSpPr>` + ph + `<p:spPr/>` +
|
||||
`<p:txBody><a:bodyPr/><a:p><a:r><a:t>` + text + `</a:t></a:r></a:p></p:txBody></p:sp>`
|
||||
}
|
||||
|
||||
func pptxFixture() map[string]string {
|
||||
presRel := func(id, target string) string {
|
||||
return `<Relationship Id="` + id + `" Type="` + relTypeNS + `/slide" Target="` + target + `"/>`
|
||||
}
|
||||
slide1 := `<p:sld xmlns:p="` + pNS + `" xmlns:a="` + aNS + `" xmlns:r="` + relTypeNS + `"><p:cSld><p:spTree>
|
||||
<p:nvGrpSpPr/><p:grpSpPr/>
|
||||
` + sp("title", "封面标题") + `
|
||||
` + sp("body", "") + `
|
||||
<p:sp><p:nvSpPr><p:cNvPr id="4" name="B"/><p:cNvSpPr/></p:nvSpPr><p:nvPr/><p:spPr/><p:txBody><a:bodyPr/>
|
||||
<a:p><a:r><a:t>第一行</a:t></a:r><a:br/><a:r><a:t>第二行</a:t></a:r></a:p></p:txBody></p:sp>
|
||||
<p:grpSp><p:nvGrpSpPr/><p:grpSpPr/>` + sp("", "组合内文本") + `</p:grpSp>
|
||||
<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006">
|
||||
<mc:Choice Requires="p14">` + sp("", "选内文本") + `</mc:Choice>
|
||||
<mc:Fallback>` + sp("", "弃内文本") + `</mc:Fallback>
|
||||
</mc:AlternateContent>
|
||||
<p:sp><p:nvSpPr><p:cNvPr id="6" name="N"/><p:cNvSpPr/></p:nvSpPr><p:nvPr><p:ph type="sldNum"/></p:nvPr><p:spPr/><p:txBody><a:p><a:fld id="{F1}"><a:t>3</a:t></a:fld></a:p></p:txBody></p:sp>
|
||||
<p:graphicFrame><p:nvGraphicFramePr/><p:xfrm/><a:graphic><a:graphicData uri="` + aNS + `/table"><a:tbl>
|
||||
<a:tr h="1"><a:tc gridSpan="2"><a:txBody><a:p><a:r><a:t>合并头</a:t></a:r></a:p></a:txBody></a:tc><a:tc><a:txBody><a:p><a:r><a:t>列三</a:t></a:r></a:p></a:txBody></a:tc></a:tr>
|
||||
<a:tr h="1"><a:tc hMerge="1"><a:txBody><a:p/></a:txBody></a:tc><a:tc hMerge="1"><a:txBody><a:p/></a:txBody></a:tc><a:tc><a:txBody><a:p><a:r><a:t>值三</a:t></a:r></a:p></a:txBody></a:tc></a:tr>
|
||||
</a:tbl></a:graphicData></a:graphic></p:graphicFrame>
|
||||
</p:spTree></p:cSld></p:sld>`
|
||||
slide2 := `<p:sld xmlns:p="` + pNS + `" xmlns:a="` + aNS + `"><p:cSld><p:spTree>
|
||||
<p:nvGrpSpPr/><p:grpSpPr/>` + sp("body", "第二页要点") + `
|
||||
</p:spTree></p:cSld></p:sld>`
|
||||
return map[string]string{
|
||||
"ppt/presentation.xml": `<p:presentation xmlns:p="` + pNS + `" xmlns:r="` + relTypeNS + `"><p:sldIdLst>
|
||||
<p:sldId id="256" r:id="rIdA"/><p:sldId id="257" r:id="rIdB"/>
|
||||
</p:sldIdLst></p:presentation>`,
|
||||
"ppt/_rels/presentation.xml.rels": `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
|
||||
presRel("rIdA", "slides/slide1.xml") + presRel("rIdB", "slides/slide2.xml") + `</Relationships>`,
|
||||
"ppt/slides/slide1.xml": slide1,
|
||||
"ppt/slides/slide2.xml": slide2,
|
||||
"ppt/slides/_rels/slide1.xml.rels": `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdN" Type="` + relTypeNS + `/notesSlide" Target="../notesSlides/notesSlide1.xml"/></Relationships>`,
|
||||
// slide2 的备注故意缺失(无 rels 文件)。
|
||||
"ppt/notesSlides/notesSlide1.xml": `<p:notes xmlns:p="` + pNS + `" xmlns:a="` + aNS + `"><p:cSld><p:spTree>
|
||||
<p:nvGrpSpPr/><p:grpSpPr/>` + sp("body", "补充说明") + `
|
||||
</p:spTree></p:cSld></p:notes>`,
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePptxBuiltFromParts(t *testing.T) {
|
||||
doc := Parse("演示.pptx", "", zipBytes(t, pptxFixture()))
|
||||
if doc.Meta.Parser != "builtin:pptx" || len(doc.Warnings) != 0 || doc.Meta.Confidence != 0.9 {
|
||||
t.Fatalf("meta: %+v warnings: %v", doc.Meta, doc.Warnings)
|
||||
}
|
||||
var heads []string
|
||||
for _, b := range doc.Blocks {
|
||||
if b.Type == core.BlockHeading {
|
||||
heads = append(heads, b.Text)
|
||||
}
|
||||
}
|
||||
// sldIdLst 顺序:slide1(有标题)在前;slide2 无标题占位符 → 无标题兜底。
|
||||
if len(heads) != 2 || heads[0] != "幻灯片 1:封面标题" || heads[1] != "幻灯片 2" {
|
||||
t.Fatalf("headings: %v", heads)
|
||||
}
|
||||
plain := doc.PlainText()
|
||||
for _, want := range []string{"第一行\n第二行", "组合内文本", "选内文本", "备注:补充说明"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Errorf("missing %q in plain text", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(plain, "弃内文本") {
|
||||
t.Error("mc:Fallback content must be skipped")
|
||||
}
|
||||
// 页码占位符与字段文本不出现。
|
||||
if strings.Contains(plain, "\n3\n") {
|
||||
t.Error("slide number placeholder text leaked")
|
||||
}
|
||||
// 表格:gridSpan 占位 + hMerge 续格留空,保持三列对齐。
|
||||
tbl := findTable(doc, 0)
|
||||
if tbl == nil || len(tbl.Rows) != 2 {
|
||||
t.Fatalf("table: %+v", tbl)
|
||||
}
|
||||
if strings.Join(tbl.Rows[0], "|") != "合并头||列三" {
|
||||
t.Errorf("row1: %v", tbl.Rows[0])
|
||||
}
|
||||
if strings.Join(tbl.Rows[1], "|") != "||值三" {
|
||||
t.Errorf("row2: %v", tbl.Rows[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePptxInvalidAndEmpty(t *testing.T) {
|
||||
doc := Parse("坏.pptx", "", []byte("garbage"))
|
||||
if len(doc.Warnings) == 0 || len(doc.Blocks) != 0 || doc.Meta.Confidence != 0.3 {
|
||||
t.Fatalf("invalid pptx: %+v %v", doc.Meta, doc.Warnings)
|
||||
}
|
||||
files := pptxFixture()
|
||||
files["ppt/presentation.xml"] = `<p:presentation xmlns:p="` + pNS + `"></p:presentation>`
|
||||
doc = Parse("空演示.pptx", "", zipBytes(t, files))
|
||||
if len(doc.Warnings) == 0 || len(doc.Blocks) != 0 {
|
||||
t.Fatalf("no slides: %v", doc.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLegacyOfficeHint(t *testing.T) {
|
||||
for ext, want := range map[string]string{
|
||||
"doc": "docx", "xls": "xlsx", "ppt": "pptx",
|
||||
} {
|
||||
doc := Parse("旧档."+ext, "", []byte{0xD0, 0xCF})
|
||||
if doc.Meta.Parser != "builtin:legacy-office" {
|
||||
t.Fatalf("%s parser: %+v", ext, doc.Meta)
|
||||
}
|
||||
if len(doc.Blocks) != 0 || len(doc.Warnings) != 1 {
|
||||
t.Fatalf("%s blocks/warnings: %+v %v", ext, doc.Blocks, doc.Warnings)
|
||||
}
|
||||
if !strings.Contains(doc.Warnings[0], want) {
|
||||
t.Errorf("%s hint should mention %s: %v", ext, want, doc.Warnings[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 注册表与分派行为一致:每个已注册格式的解析器 ID 与 Parse 分派结果一致,
|
||||
// 且 builtin 解析器覆盖的扩展名都出现在注册表里。
|
||||
func TestParsersDescriptionMatchesDispatch(t *testing.T) {
|
||||
byFormat := map[string]string{}
|
||||
for _, d := range ParsersDescription() {
|
||||
if d.Source != core.SourceBuiltin {
|
||||
continue
|
||||
}
|
||||
for _, f := range d.Formats {
|
||||
byFormat[f] = d.ID
|
||||
}
|
||||
}
|
||||
check := map[string]string{
|
||||
"md": "builtin:md", "markdown": "builtin:md",
|
||||
"txt": "builtin:txt", "text": "builtin:txt", "csv": "builtin:txt", "log": "builtin:txt",
|
||||
"docx": "builtin:docx", "pdf": "builtin:pdf",
|
||||
"xlsx": "builtin:xlsx", "xlsm": "builtin:xlsx",
|
||||
"pptx": "builtin:pptx", "pptm": "builtin:pptx", "ppsx": "builtin:pptx",
|
||||
"doc": "builtin:legacy-office", "xls": "builtin:legacy-office", "ppt": "builtin:legacy-office",
|
||||
}
|
||||
for ext, wantID := range check {
|
||||
got, ok := byFormat[ext]
|
||||
if !ok {
|
||||
t.Errorf("format .%s missing from registry", ext)
|
||||
continue
|
||||
}
|
||||
doc := Parse("a."+ext, "", []byte("x"))
|
||||
if doc.Meta.Parser != wantID || got != wantID {
|
||||
t.Errorf(".%s: registry=%s dispatch=%s want=%s", ext, got, doc.Meta.Parser, wantID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseXlsxWideColumnSkipsCellNotSheets:稀疏样式单元格落在远端列(XFD 区,
|
||||
// 列序 ≥1024)只跳过该格并告警——不误报单元格上限、不丢弃后续工作表。
|
||||
func TestParseXlsxWideColumnSkipsCellNotSheets(t *testing.T) {
|
||||
files := xlsxFixture()
|
||||
// 第一表加一个列引用 XFD1(16384 列)的样式单元格;第二表(隐藏的"草稿")必须照常解析。
|
||||
files["xl/worksheets/sheet1.xml"] = strings.Replace(files["xl/worksheets/sheet1.xml"],
|
||||
"</row>\n<row r=\"4\"", "</row>\n<row r=\"3\"><c r=\"XFD3\" t=\"inlineStr\"><is><t>边角样式</t></is></c></row>\n<row r=\"4\"", 1)
|
||||
doc := Parse("宽表.xlsx", "", zipBytes(t, files))
|
||||
heads := []string{}
|
||||
for _, b := range doc.Blocks {
|
||||
if b.Type == core.BlockHeading {
|
||||
heads = append(heads, b.Text)
|
||||
}
|
||||
}
|
||||
if strings.Join(heads, "|") != "报价|草稿(隐藏)" {
|
||||
t.Fatalf("later sheets must survive a wide column: headings=%v warnings=%v", heads, doc.Warnings)
|
||||
}
|
||||
joined := strings.Join(doc.Warnings, ";")
|
||||
if !strings.Contains(joined, "超过 1024 列") {
|
||||
t.Fatalf("wide-column warning missing: %v", doc.Warnings)
|
||||
}
|
||||
if strings.Contains(joined, "单元格总数") {
|
||||
t.Fatalf("cell-cap warning must not fire for a wide column: %v", doc.Warnings)
|
||||
}
|
||||
if doc.Meta.Confidence != 0.6 {
|
||||
t.Fatalf("partial-content confidence expected 0.6: %v", doc.Meta.Confidence)
|
||||
}
|
||||
// 被跳过的远端列内容不得混进表格。
|
||||
for _, b := range doc.Blocks {
|
||||
if b.Type == core.BlockTable {
|
||||
for _, row := range b.Rows {
|
||||
for _, cell := range row {
|
||||
if strings.Contains(cell, "边角样式") {
|
||||
t.Fatalf("skipped wide-column cell leaked into table: %v", row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseXlsxBadSheetXMLWarns:单张工作表 XML 损坏必须显式告警(其余表照常),
|
||||
// 与提案「显式失败,不静默」一致。
|
||||
func TestParseXlsxBadSheetXMLWarns(t *testing.T) {
|
||||
files := xlsxFixture()
|
||||
files["xl/worksheets/sheet1.xml"] = `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row`
|
||||
doc := Parse("坏表.xlsx", "", zipBytes(t, files))
|
||||
if len(doc.Warnings) == 0 {
|
||||
t.Fatal("malformed sheet XML must warn")
|
||||
}
|
||||
joined := strings.Join(doc.Warnings, ";")
|
||||
if !strings.Contains(joined, "工作表 报价") || !strings.Contains(joined, "XML 解码失败") {
|
||||
t.Fatalf("warning should name the sheet and the failure: %v", doc.Warnings)
|
||||
}
|
||||
// 第二张表照常解析。
|
||||
found := false
|
||||
for _, b := range doc.Blocks {
|
||||
if b.Type == core.BlockHeading && b.Text == "草稿(隐藏)" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("healthy sheets must still parse: %+v", doc.Blocks)
|
||||
}
|
||||
if doc.Meta.Confidence != 0.6 {
|
||||
t.Fatalf("partial parse confidence expected 0.6: %v", doc.Meta.Confidence)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package ingest
|
||||
|
||||
// pptx/pptm/ppsx(PresentationML)内置解析器。幻灯片顺序取 presentation.xml
|
||||
// 的 sldIdLst;标题占位符(title/ctrTitle)产标题块,正文形状按段落输出
|
||||
//(a:br 换行),组合形状经同一 token 走查天然递归;mc:AlternateContent 只取
|
||||
// Choice 分支;graphicFrame 内的表格按 tr/tc 输出(gridSpan/hMerge 占位保列
|
||||
// 对齐);备注页正文输出「备注:…」;页码/日期/页脚占位符跳过。图片与
|
||||
// SmartArt 不解析。纯标准库。
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func parsePptx(content []byte) ([]core.DocBlock, []string) {
|
||||
zr, err := openZip(content)
|
||||
if err != nil {
|
||||
return nil, []string{"pptx 解析失败:" + err.Error()}
|
||||
}
|
||||
slides, warns := pptxSlideOrder(zr)
|
||||
if slides == nil && len(warns) > 0 {
|
||||
return nil, warns
|
||||
}
|
||||
var blocks []core.DocBlock
|
||||
for i, sp := range slides {
|
||||
n := i + 1
|
||||
data, err := zipPart(zr, sp)
|
||||
if err != nil {
|
||||
warns = append(warns, fmt.Sprintf("幻灯片 %d 读取失败:%v", n, err))
|
||||
blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: 2, Text: fmt.Sprintf("幻灯片 %d", n)})
|
||||
continue
|
||||
}
|
||||
title, paras, tables := pptxSlideBlocks(data)
|
||||
heading := fmt.Sprintf("幻灯片 %d", n)
|
||||
if title != "" {
|
||||
heading += ":" + title
|
||||
}
|
||||
blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: 2, Text: heading})
|
||||
blocks = append(blocks, paras...)
|
||||
blocks = append(blocks, tables...)
|
||||
if note := pptxSlideNotes(zr, sp); note != "" {
|
||||
blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: "备注:" + note})
|
||||
}
|
||||
}
|
||||
if len(blocks) == 0 && len(warns) == 0 {
|
||||
warns = append(warns, "pptx 无可解析的幻灯片内容")
|
||||
}
|
||||
return blocks, warns
|
||||
}
|
||||
|
||||
// pptxSlideOrder 读 presentation.xml 的 sldIdLst → 幻灯片部件名(按顺序)。
|
||||
func pptxSlideOrder(zr *zip.Reader) ([]string, []string) {
|
||||
data, err := zipPart(zr, "ppt/presentation.xml")
|
||||
if err != nil {
|
||||
return nil, []string{"pptx 解析失败:" + err.Error()}
|
||||
}
|
||||
rels, err := parseRels(zr, "ppt/presentation.xml")
|
||||
if err != nil {
|
||||
return nil, []string{"pptx 解析失败:" + err.Error()}
|
||||
}
|
||||
dec := xml.NewDecoder(bytes.NewReader(data))
|
||||
var out, warns []string
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return out, append(warns, "presentation.xml 解码失败:"+err.Error())
|
||||
}
|
||||
st, ok := tok.(xml.StartElement)
|
||||
if !ok || st.Name.Local != "sldId" {
|
||||
continue
|
||||
}
|
||||
rel, ok := rels[ridAttr(st)]
|
||||
if !ok || !relIsType(rel, "/slide") {
|
||||
warns = append(warns, "幻灯片关系缺失或类型不符")
|
||||
continue
|
||||
}
|
||||
out = append(out, resolveRelTarget("ppt", rel.Target))
|
||||
}
|
||||
return out, warns
|
||||
}
|
||||
|
||||
// pptxSlideBlocks 解析一张幻灯片:标题、正文段落、表格块。
|
||||
// 表格块统一附在段落后(图形框与正文的精确穿插对检索无价值)。
|
||||
func pptxSlideBlocks(data []byte) (title string, paras []core.DocBlock, tables []core.DocBlock) {
|
||||
dec := xml.NewDecoder(bytes.NewReader(data))
|
||||
var stack []string
|
||||
has := func(name string) bool {
|
||||
for _, s := range stack {
|
||||
if s == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
skip := 0 // mc:Fallback 子树深度
|
||||
// 形状状态
|
||||
spDepth := -1
|
||||
phSkip := false
|
||||
isTitle := false
|
||||
var spParas []string
|
||||
var para strings.Builder
|
||||
paraOpen := false
|
||||
// 文本收集目标
|
||||
textInPara := false
|
||||
textInCell := false
|
||||
// 表格状态
|
||||
var tblRows [][]string
|
||||
var tblRow []string
|
||||
var cellBuf strings.Builder
|
||||
cellOpen := false
|
||||
gridSpan := 1
|
||||
cellHM := false
|
||||
|
||||
finishPara := func() {
|
||||
if !paraOpen {
|
||||
return
|
||||
}
|
||||
paraOpen = false
|
||||
if s := strings.TrimRight(para.String(), "\n"); strings.TrimSpace(s) != "" {
|
||||
spParas = append(spParas, strings.TrimSpace(s))
|
||||
}
|
||||
para.Reset()
|
||||
}
|
||||
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return "", paras, tables
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if skip > 0 {
|
||||
skip++
|
||||
continue
|
||||
}
|
||||
local := t.Name.Local
|
||||
switch local {
|
||||
case "Fallback":
|
||||
if has("AlternateContent") {
|
||||
skip = 1
|
||||
continue
|
||||
}
|
||||
case "sp":
|
||||
spDepth = len(stack)
|
||||
phSkip, isTitle, spParas = false, false, nil
|
||||
case "ph":
|
||||
if spDepth >= 0 && len(stack) > spDepth {
|
||||
switch attrLocal(t, "type") {
|
||||
case "title", "ctrTitle":
|
||||
isTitle = true
|
||||
case "dt", "ftr", "sldNum":
|
||||
phSkip = true
|
||||
}
|
||||
}
|
||||
case "p":
|
||||
if spDepth >= 0 && has("txBody") && !has("tbl") {
|
||||
finishPara()
|
||||
paraOpen = true
|
||||
para.Reset()
|
||||
} else if cellOpen {
|
||||
if cellBuf.Len() > 0 {
|
||||
cellBuf.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
case "br":
|
||||
if paraOpen {
|
||||
para.WriteByte('\n')
|
||||
}
|
||||
case "t":
|
||||
if has("fld") {
|
||||
break // 字段(页码/日期)不取
|
||||
}
|
||||
textInPara = paraOpen
|
||||
textInCell = cellOpen && !paraOpen
|
||||
case "tc":
|
||||
if has("tbl") {
|
||||
cellBuf.Reset()
|
||||
cellOpen = true
|
||||
gridSpan, _ = strconv.Atoi(attrLocal(t, "gridSpan"))
|
||||
if gridSpan < 1 {
|
||||
gridSpan = 1
|
||||
}
|
||||
hm := attrLocal(t, "hMerge")
|
||||
cellHM = hm == "1" || strings.EqualFold(hm, "true")
|
||||
}
|
||||
}
|
||||
stack = append(stack, local)
|
||||
case xml.CharData:
|
||||
if textInPara {
|
||||
para.Write(t)
|
||||
} else if textInCell {
|
||||
cellBuf.Write(t)
|
||||
}
|
||||
case xml.EndElement:
|
||||
if skip > 0 {
|
||||
skip--
|
||||
continue
|
||||
}
|
||||
if len(stack) > 0 {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
switch t.Name.Local {
|
||||
case "t":
|
||||
textInPara, textInCell = false, false
|
||||
case "p":
|
||||
finishPara()
|
||||
case "sp":
|
||||
if spDepth >= 0 && len(stack) == spDepth {
|
||||
if !phSkip && len(spParas) > 0 {
|
||||
if isTitle && title == "" {
|
||||
title = strings.Join(spParas, " ")
|
||||
} else {
|
||||
for _, p := range spParas {
|
||||
paras = append(paras, core.DocBlock{Type: core.BlockPara, Text: p})
|
||||
}
|
||||
}
|
||||
}
|
||||
spDepth = -1
|
||||
phSkip, isTitle, spParas = false, false, nil
|
||||
}
|
||||
case "tc":
|
||||
if cellOpen {
|
||||
text := strings.TrimSpace(cellBuf.String())
|
||||
if cellHM {
|
||||
text = "" // 横向合并的续格:占位保列对齐
|
||||
}
|
||||
tblRow = append(tblRow, text)
|
||||
for i := 1; i < gridSpan; i++ {
|
||||
tblRow = append(tblRow, "") // 合并占位保列对齐
|
||||
}
|
||||
cellOpen = false
|
||||
}
|
||||
case "tr":
|
||||
if len(tblRow) > 0 {
|
||||
for len(tblRow) > 0 && tblRow[len(tblRow)-1] == "" {
|
||||
tblRow = tblRow[:len(tblRow)-1]
|
||||
}
|
||||
if len(tblRow) > 0 {
|
||||
tblRows = append(tblRows, tblRow)
|
||||
}
|
||||
tblRow = nil
|
||||
}
|
||||
case "tbl":
|
||||
if len(tblRows) > 0 {
|
||||
tables = append(tables, core.DocBlock{Type: core.BlockTable, Rows: tblRows})
|
||||
}
|
||||
tblRows = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
finishPara()
|
||||
return title, paras, tables
|
||||
}
|
||||
|
||||
// pptxSlideNotes 读幻灯片的备注页正文;无备注返回空串。
|
||||
func pptxSlideNotes(zr *zip.Reader, slidePart string) string {
|
||||
rels, err := parseRels(zr, slidePart)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, rel := range rels {
|
||||
if !relIsType(rel, "/notesSlide") {
|
||||
continue
|
||||
}
|
||||
data, err := zipPart(zr, resolveRelTarget(path.Dir(slidePart), rel.Target))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
_, paras, _ := pptxSlideBlocks(data)
|
||||
var lines []string
|
||||
for _, p := range paras {
|
||||
lines = append(lines, p.Text)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package ingest
|
||||
|
||||
// xlsx/xlsm(SpreadsheetML)内置解析器。工作表顺序取 workbook.xml 的 sheets
|
||||
// 列表(target 经关系部件解析,支持相对/绝对路径);共享字符串含富文本 run
|
||||
//(注音 rPh 无对应解码字段,天然排除);单元格覆盖 inlineStr/str/b/e/n,
|
||||
// 公式取缓存值、无缓存回显公式;日期按数字格式(内置 ID 与自定义记号)转
|
||||
// ISO,1900/1904 双日期系统;稀疏单元格按引用就位。纯标准库。
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 解析上限(包级变量,测试可覆盖)。
|
||||
var (
|
||||
xlsxMaxCells = 200000
|
||||
xlsxMaxCols = 1024
|
||||
)
|
||||
|
||||
type xlsxSheetRef struct {
|
||||
Name string
|
||||
State string
|
||||
Path string
|
||||
IsSheet bool
|
||||
}
|
||||
|
||||
func parseXlsx(content []byte) ([]core.DocBlock, []string) {
|
||||
zr, err := openZip(content)
|
||||
if err != nil {
|
||||
return nil, []string{"xlsx 解析失败:" + err.Error()}
|
||||
}
|
||||
sheets, date1904, warns := xlsxWorkbookSheets(zr)
|
||||
if len(warns) > 0 && sheets == nil {
|
||||
return nil, warns
|
||||
}
|
||||
shared := xlsxSharedStrings(zr)
|
||||
dateStyles := xlsxDateStyles(zr)
|
||||
var blocks []core.DocBlock
|
||||
cells := 0
|
||||
for _, sh := range sheets {
|
||||
if !sh.IsSheet || sh.Path == "" {
|
||||
continue // chartsheet 等非工作表部件
|
||||
}
|
||||
data, err := zipPart(zr, sh.Path)
|
||||
if err != nil {
|
||||
warns = append(warns, fmt.Sprintf("工作表 %s 读取失败:%v", sh.Name, err))
|
||||
continue
|
||||
}
|
||||
table, used, trunc, wide, sheetWarn := xlsxSheetBlocks(data, shared, dateStyles, date1904, cells)
|
||||
cells = used
|
||||
if sheetWarn != "" {
|
||||
warns = append(warns, fmt.Sprintf("工作表 %s:%s", sh.Name, sheetWarn))
|
||||
}
|
||||
if wide {
|
||||
warns = append(warns, fmt.Sprintf("工作表 %s 含超过 %d 列的稀疏单元格,已跳过该列(其余内容不受影响)", sh.Name, xlsxMaxCols))
|
||||
}
|
||||
if table == nil {
|
||||
continue // 空表(读取失败/坏 XML 已单独告警)
|
||||
}
|
||||
title := sh.Name
|
||||
if sh.State == "hidden" || sh.State == "veryHidden" {
|
||||
title += "(隐藏)"
|
||||
}
|
||||
blocks = append(blocks,
|
||||
core.DocBlock{Type: core.BlockHeading, Level: 2, Text: title},
|
||||
*table,
|
||||
)
|
||||
if trunc {
|
||||
warns = append(warns, fmt.Sprintf("单元格总数超过 %d 上限,工作表 %s 之后未解析", xlsxMaxCells, sh.Name))
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(blocks) == 0 && len(warns) == 0 {
|
||||
warns = append(warns, "xlsx 无可解析的工作表内容")
|
||||
}
|
||||
return blocks, warns
|
||||
}
|
||||
|
||||
// xlsxWorkbookSheets 读 workbook.xml:工作表顺序、隐藏状态、1904 日期系统。
|
||||
func xlsxWorkbookSheets(zr *zip.Reader) (sheets []xlsxSheetRef, date1904 bool, warns []string) {
|
||||
data, err := zipPart(zr, "xl/workbook.xml")
|
||||
if err != nil {
|
||||
return nil, false, []string{"xlsx 解析失败:" + err.Error()}
|
||||
}
|
||||
rels, err := parseRels(zr, "xl/workbook.xml")
|
||||
if err != nil {
|
||||
return nil, false, []string{"xlsx 解析失败:" + err.Error()}
|
||||
}
|
||||
dec := xml.NewDecoder(bytes.NewReader(data))
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return sheets, date1904, append(warns, "workbook.xml 解码失败:"+err.Error())
|
||||
}
|
||||
st, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch st.Name.Local {
|
||||
case "workbookPr":
|
||||
if v := attrLocal(st, "date1904"); v == "1" || strings.EqualFold(v, "true") {
|
||||
date1904 = true
|
||||
}
|
||||
case "sheet":
|
||||
ref := xlsxSheetRef{Name: attrLocal(st, "name"), State: attrLocal(st, "state")}
|
||||
if rel, ok := rels[ridAttr(st)]; ok {
|
||||
ref.Path = resolveRelTarget("xl", rel.Target)
|
||||
ref.IsSheet = relIsType(rel, "/worksheet")
|
||||
} else {
|
||||
warns = append(warns, fmt.Sprintf("工作表 %s 的关系缺失", ref.Name))
|
||||
}
|
||||
sheets = append(sheets, ref)
|
||||
}
|
||||
}
|
||||
return sheets, date1904, warns
|
||||
}
|
||||
|
||||
// xlsxSharedStrings 读共享字符串表;富文本按 run 拼接,rPh 注音不进结果。
|
||||
func xlsxSharedStrings(zr *zip.Reader) []string {
|
||||
data, err := zipPart(zr, "xl/sharedStrings.xml")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
dec := xml.NewDecoder(bytes.NewReader(data))
|
||||
var out []string
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
st, ok := tok.(xml.StartElement)
|
||||
if !ok || st.Name.Local != "si" {
|
||||
continue
|
||||
}
|
||||
var si struct {
|
||||
T string `xml:"t"`
|
||||
Runs []struct {
|
||||
T string `xml:"t"`
|
||||
} `xml:"r"`
|
||||
}
|
||||
if err := dec.DecodeElement(&si, &st); err != nil {
|
||||
continue
|
||||
}
|
||||
if si.T != "" || len(si.Runs) == 0 {
|
||||
out = append(out, si.T)
|
||||
continue
|
||||
}
|
||||
parts := make([]string, 0, len(si.Runs))
|
||||
for _, r := range si.Runs {
|
||||
parts = append(parts, r.T)
|
||||
}
|
||||
out = append(out, strings.Join(parts, ""))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// xlsxDateStyles 返回按日期呈现的样式索引集合(cellXfs 下标)。
|
||||
func xlsxDateStyles(zr *zip.Reader) map[int]bool {
|
||||
data, err := zipPart(zr, "xl/styles.xml")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
custom := map[int]string{}
|
||||
date := map[int]bool{}
|
||||
dec := xml.NewDecoder(bytes.NewReader(data))
|
||||
inCellXfs := false
|
||||
xfIdx := 0
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return date
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
switch t.Name.Local {
|
||||
case "numFmt":
|
||||
id, _ := strconv.Atoi(attrLocal(t, "numFmtId"))
|
||||
custom[id] = attrLocal(t, "formatCode")
|
||||
case "cellXfs":
|
||||
inCellXfs = true
|
||||
xfIdx = 0
|
||||
case "xf":
|
||||
if !inCellXfs {
|
||||
continue
|
||||
}
|
||||
id, _ := strconv.Atoi(attrLocal(t, "numFmtId"))
|
||||
if xlsxFmtIsDate(id, custom) {
|
||||
date[xfIdx] = true
|
||||
}
|
||||
xfIdx++
|
||||
}
|
||||
case xml.EndElement:
|
||||
if t.Name.Local == "cellXfs" {
|
||||
inCellXfs = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
// xlsxFmtIsDate:自定义格式按记号判断;内置 ID 覆盖常见日期/时间区间。
|
||||
func xlsxFmtIsDate(id int, custom map[int]string) bool {
|
||||
if code, ok := custom[id]; ok {
|
||||
return formatCodeIsDate(code)
|
||||
}
|
||||
switch {
|
||||
case id >= 14 && id <= 22, id >= 27 && id <= 36,
|
||||
id >= 45 && id <= 47, id >= 50 && id <= 58:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// formatCodeIsDate 剥掉引号字面量与方括号节([Red]、[DBNum1]、[$-409] 等)后,
|
||||
// 剩余字母含 y/d/h/s/m 记号即按日期处理。
|
||||
func formatCodeIsDate(code string) bool {
|
||||
var b strings.Builder
|
||||
inQuote, inBracket := false, false
|
||||
runes := []rune(code)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
r := runes[i]
|
||||
switch {
|
||||
case r == '"':
|
||||
inQuote = !inQuote
|
||||
case inQuote:
|
||||
case r == '[':
|
||||
inBracket = true
|
||||
case r == ']':
|
||||
inBracket = false
|
||||
case inBracket:
|
||||
case r == '\\':
|
||||
i++ // 转义字符:连同下一字符跳过
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.ContainsAny(strings.ToLower(b.String()), "ydhsm")
|
||||
}
|
||||
|
||||
type xlsxCellEl struct {
|
||||
Ref string `xml:"r,attr"`
|
||||
T string `xml:"t,attr"`
|
||||
S string `xml:"s,attr"`
|
||||
IS *struct {
|
||||
T string `xml:"t"`
|
||||
} `xml:"is"`
|
||||
F string `xml:"f"`
|
||||
V string `xml:"v"`
|
||||
}
|
||||
|
||||
type xlsxRowEl struct {
|
||||
Cells []xlsxCellEl `xml:"c"`
|
||||
}
|
||||
|
||||
// xlsxSheetBlocks 解析单张工作表;返回表格块、累计单元格数、是否触达单元格
|
||||
// 上限、是否跳过超宽列、以及坏 XML 的告警(显式失败,不静默)。无内容行
|
||||
// 返回 nil 表。
|
||||
func xlsxSheetBlocks(data []byte, shared []string, dateStyles map[int]bool, date1904 bool, cellsUsed int) (*core.DocBlock, int, bool, bool, string) {
|
||||
dec := xml.NewDecoder(bytes.NewReader(data))
|
||||
var rows [][]string
|
||||
count := cellsUsed
|
||||
truncated := false
|
||||
wide := false
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, count, truncated, wide, "XML 解码失败:" + err.Error()
|
||||
}
|
||||
st, ok := tok.(xml.StartElement)
|
||||
if !ok || st.Name.Local != "row" {
|
||||
continue
|
||||
}
|
||||
var re xlsxRowEl
|
||||
if err := dec.DecodeElement(&re, &st); err != nil {
|
||||
continue
|
||||
}
|
||||
cells := map[int]string{}
|
||||
maxCol := -1
|
||||
for _, c := range re.Cells {
|
||||
if count >= xlsxMaxCells {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
count++
|
||||
col := xlsxColIndex(c.Ref)
|
||||
if col < 0 {
|
||||
col = maxCol + 1 // 无引用:按出现顺序接续
|
||||
}
|
||||
if col >= xlsxMaxCols {
|
||||
// 稀疏样式单元格常落在远端列(XFD 区):跳过该格并告警,
|
||||
// 不中断本表其余内容,也不阻断后续工作表。
|
||||
wide = true
|
||||
continue
|
||||
}
|
||||
cells[col] = xlsxCellText(c, shared, dateStyles, date1904)
|
||||
if col > maxCol {
|
||||
maxCol = col
|
||||
}
|
||||
}
|
||||
if len(cells) == 0 {
|
||||
continue
|
||||
}
|
||||
full := make([]string, maxCol+1)
|
||||
for i, v := range cells {
|
||||
full[i] = v
|
||||
}
|
||||
for len(full) > 0 && strings.TrimSpace(full[len(full)-1]) == "" {
|
||||
full = full[:len(full)-1]
|
||||
}
|
||||
if len(full) > 0 {
|
||||
rows = append(rows, full)
|
||||
}
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, count, truncated, wide, ""
|
||||
}
|
||||
return &core.DocBlock{Type: core.BlockTable, Rows: rows}, count, truncated, wide, ""
|
||||
}
|
||||
|
||||
// xlsxCellText 把单元格转成展示文本。
|
||||
func xlsxCellText(c xlsxCellEl, shared []string, dateStyles map[int]bool, date1904 bool) string {
|
||||
text := ""
|
||||
switch c.T {
|
||||
case "s":
|
||||
if idx, err := strconv.Atoi(c.V); err == nil && idx >= 0 && idx < len(shared) {
|
||||
text = shared[idx]
|
||||
}
|
||||
case "inlineStr":
|
||||
if c.IS != nil {
|
||||
text = c.IS.T
|
||||
}
|
||||
case "b":
|
||||
if c.V == "1" || strings.EqualFold(c.V, "true") {
|
||||
text = "TRUE"
|
||||
} else {
|
||||
text = "FALSE"
|
||||
}
|
||||
case "e":
|
||||
text = c.V
|
||||
default: // n 或空:数字 / 公式缓存值
|
||||
text = c.V
|
||||
if sIdx, err := strconv.Atoi(c.S); err == nil && dateStyles[sIdx] && c.V != "" {
|
||||
if f, err := strconv.ParseFloat(c.V, 64); err == nil {
|
||||
text = xlsxSerialString(f, date1904)
|
||||
}
|
||||
}
|
||||
}
|
||||
if text == "" && c.F != "" {
|
||||
return "=" + c.F // 无缓存值的公式:回显公式本身
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// xlsxSerialString Excel 日期序列值 → ISO 字符串;带小数部分时输出到秒。
|
||||
func xlsxSerialString(serial float64, date1904 bool) string {
|
||||
days := math.Floor(serial)
|
||||
frac := serial - days
|
||||
var t time.Time
|
||||
switch {
|
||||
case date1904:
|
||||
t = time.Date(1904, 1, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 0, int(days))
|
||||
case days >= 61:
|
||||
t = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC).AddDate(0, 0, int(days))
|
||||
default: // 1–60:兼容 Excel 的 1900 闰年 bug 区段
|
||||
t = time.Date(1899, 12, 31, 0, 0, 0, 0, time.UTC).AddDate(0, 0, int(days))
|
||||
}
|
||||
if frac > 0 {
|
||||
return t.Add(time.Duration(frac * 86400 * float64(time.Second))).Round(time.Second).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// xlsxColIndex "BC12" → 0 起列号;无引用或非法返回 -1。
|
||||
func xlsxColIndex(ref string) int {
|
||||
if ref == "" {
|
||||
return -1
|
||||
}
|
||||
i := 0
|
||||
for i < len(ref) {
|
||||
ch := ref[i]
|
||||
if (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if i == 0 || i == len(ref) {
|
||||
return -1
|
||||
}
|
||||
for _, ch := range ref[i:] {
|
||||
if ch < '0' || ch > '9' {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
n := 0
|
||||
for _, ch := range ref[:i] {
|
||||
c := ch
|
||||
if c >= 'a' && c <= 'z' {
|
||||
c = c - 'a' + 'A'
|
||||
}
|
||||
n = n*26 + int(c-'A') + 1
|
||||
}
|
||||
return n - 1
|
||||
}
|
||||
@@ -24,20 +24,74 @@ type MatchResult struct {
|
||||
ID string
|
||||
Slots map[string]string
|
||||
Missing []SlotQuestion
|
||||
// Commands 是库层展开的命令列表(I1);Router 消费它做唯一裁决。
|
||||
// guard_hit/expectation_hit 被折叠为 fallback 时为空。
|
||||
Commands []Command
|
||||
}
|
||||
|
||||
// 命令类型经闸门投影(别名):生态库按边界规则只许本包导入,
|
||||
// Router/runmgr 等消费方只依赖本包表面。
|
||||
type (
|
||||
Command = agentintent.Command
|
||||
ActionRef = agentintent.ActionRef
|
||||
)
|
||||
|
||||
const (
|
||||
CommandStart = agentintent.CommandStart
|
||||
ActionAuto = agentintent.ActionAuto
|
||||
ActionCommand = agentintent.ActionCommand
|
||||
ActionWorkflow = agentintent.ActionWorkflow
|
||||
ActionBlueprint = agentintent.ActionBlueprint
|
||||
ActionHandler = agentintent.ActionHandler
|
||||
)
|
||||
|
||||
type Gate struct {
|
||||
mu sync.RWMutex
|
||||
reg *agentintent.Registry
|
||||
rec *agentintent.FunnelRecognizer
|
||||
specs int
|
||||
specsByID map[string]agentintent.IntentSpec
|
||||
// 慢通道(I2):nil = 不启用(默认,行为与 I1 一致)。
|
||||
sem agentintent.SemanticScorer
|
||||
semThresh float64
|
||||
gen agentintent.CommandGenerator
|
||||
}
|
||||
|
||||
func New() *Gate {
|
||||
return &Gate{rec: agentintent.NewRecognizer(agentintent.Options{}, nil, nil)}
|
||||
}
|
||||
|
||||
// SetSemantic 接线语义打分器(慢通道中位)。识别器随之重建;
|
||||
// 传 nil 关闭语义层。阈值 ≤0 用库默认(0.8)。
|
||||
func (g *Gate) SetSemantic(s agentintent.SemanticScorer, threshold float64) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.sem, g.semThresh = s, threshold
|
||||
g.rebuildRecognizerLocked()
|
||||
}
|
||||
|
||||
// SetGenerator 接线模型命令生成器(慢通道末位)。识别器随之重建;
|
||||
// 传 nil 关闭生成层。
|
||||
func (g *Gate) SetGenerator(gen agentintent.CommandGenerator) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.gen = gen
|
||||
g.rebuildRecognizerLocked()
|
||||
}
|
||||
|
||||
// rebuildRecognizerLocked 按当前慢通道配置重建识别器(调用方持写锁)。
|
||||
func (g *Gate) rebuildRecognizerLocked() {
|
||||
if g.sem == nil && g.gen == nil {
|
||||
g.rec = agentintent.NewRecognizer(agentintent.Options{}, nil, nil)
|
||||
return
|
||||
}
|
||||
g.rec = agentintent.NewRecognizer(agentintent.Options{
|
||||
Semantic: g.sem,
|
||||
SemanticThreshold: g.semThresh,
|
||||
Generator: g.gen,
|
||||
}, nil, nil)
|
||||
}
|
||||
|
||||
// Rebuild 用插件意图 overlay 重建注册表;失败保留上一快照(fail-closed)并返回错误。
|
||||
func (g *Gate) Rebuild(ctx context.Context, overlay []json.RawMessage) error {
|
||||
if len(overlay) == 0 {
|
||||
@@ -54,7 +108,7 @@ func (g *Gate) Rebuild(ctx context.Context, overlay []json.RawMessage) error {
|
||||
}
|
||||
specs = append(specs, s)
|
||||
}
|
||||
b := agentintent.NewRegistryBuilder(agentintent.Options{TopN: 3})
|
||||
b := agentintent.NewRegistryBuilder(agentintent.Options{TopN: 3, NFKC: true})
|
||||
if err := b.Register(specs...); err != nil {
|
||||
return fmt.Errorf("intentgate: register: %w", err)
|
||||
}
|
||||
@@ -95,6 +149,7 @@ func (g *Gate) Recognize(ctx context.Context, input string) MatchResult {
|
||||
switch res.Branch {
|
||||
case agentintent.BranchMatched:
|
||||
out.Branch = "matched"
|
||||
out.Commands = res.Commands
|
||||
case agentintent.BranchAmbiguous:
|
||||
out.Branch = "ambiguous"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
agentintent "agent-intent/intent"
|
||||
)
|
||||
|
||||
func raw(t *testing.T, v any) json.RawMessage {
|
||||
@@ -91,3 +93,86 @@ func TestRecognizeKeepsSlots(t *testing.T) {
|
||||
t.Fatalf("Match wrapper = %s %s", branch, id)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- I2 慢通道接线(默认关;SetSemantic/SetGenerator 开启)----
|
||||
|
||||
// stubScorer 固定返回脚本分(按 examples[0] 查表)。
|
||||
type stubScorer struct{ scores map[string]float64 }
|
||||
|
||||
func (s stubScorer) Score(_ context.Context, _ string, examples []string) (float64, error) {
|
||||
if len(examples) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return s.scores[examples[0]], nil
|
||||
}
|
||||
|
||||
// stubGenerator 固定返回脚本命令。
|
||||
type stubGenerator struct{ cmds []Command }
|
||||
|
||||
func (g stubGenerator) Generate(_ context.Context, _ string, _ []agentintent.CatalogSpec) ([]agentintent.Command, error) {
|
||||
return g.cmds, nil
|
||||
}
|
||||
|
||||
func ambiguousOverlay(t *testing.T) []json.RawMessage {
|
||||
t.Helper()
|
||||
mk := func(id string, ex []string) json.RawMessage {
|
||||
return raw(t, map[string]any{
|
||||
"id": id, "domain": "demo", "version": 1, "priority": 5,
|
||||
"matcher": map[string]any{"includes_any": []string{"模糊词"}},
|
||||
"examples": ex,
|
||||
})
|
||||
}
|
||||
return []json.RawMessage{mk("demo.alpha", []string{"查运行进度"}), mk("demo.beta", []string{"闲聊问候"})}
|
||||
}
|
||||
|
||||
// 语义层:歧义被过阈打分裁决为 matched(默认关时不启用)。
|
||||
func TestGateSemanticResolvesAmbiguous(t *testing.T) {
|
||||
g := New()
|
||||
if err := g.Rebuild(context.Background(), ambiguousOverlay(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 默认(未接线):规则层歧义原样透出。
|
||||
if branch, _ := g.Match(context.Background(), "模糊词"); branch != "ambiguous" {
|
||||
t.Fatalf("默认关:branch = %s", branch)
|
||||
}
|
||||
g.SetSemantic(stubScorer{scores: map[string]float64{"查运行进度": 0.95, "闲聊问候": 0.4}}, 0)
|
||||
branch, top := g.Match(context.Background(), "模糊词")
|
||||
if branch != "matched" || top != "demo.alpha" {
|
||||
t.Fatalf("语义裁决:%s/%s", branch, top)
|
||||
}
|
||||
// 关闭后回到歧义。
|
||||
g.SetSemantic(nil, 0)
|
||||
if branch, _ := g.Match(context.Background(), "模糊词"); branch != "ambiguous" {
|
||||
t.Fatalf("关闭语义层:branch = %s", branch)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成层:fallback 时产出合法 start 命令 → matched + Commands。
|
||||
func TestGateGeneratorProducesCommands(t *testing.T) {
|
||||
g := New()
|
||||
overlay := []json.RawMessage{raw(t, map[string]any{
|
||||
"id": "report.generate", "domain": "report", "version": 1,
|
||||
"description": "生成报告",
|
||||
"matcher": map[string]any{"includes_any": []string{"生成报告"}},
|
||||
"handler": "reportHandler",
|
||||
})}
|
||||
if err := g.Rebuild(context.Background(), overlay); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.SetGenerator(stubGenerator{cmds: []Command{{
|
||||
Kind: CommandStart,
|
||||
Action: ActionRef{Kind: ActionHandler, ID: "reportHandler"},
|
||||
}}})
|
||||
res := g.Recognize(context.Background(), "完全不相关的一句话")
|
||||
if res.Branch != "matched" || res.ID != "report.generate" {
|
||||
t.Fatalf("生成器命中:%+v", res)
|
||||
}
|
||||
if len(res.Commands) != 1 || res.Commands[0].Kind != CommandStart {
|
||||
t.Fatalf("commands: %+v", res.Commands)
|
||||
}
|
||||
// 关闭后回到 fallback。
|
||||
g.SetGenerator(nil)
|
||||
if branch, _ := g.Match(context.Background(), "完全不相关的一句话"); branch != "fallback" {
|
||||
t.Fatalf("关闭生成层:branch = %s", branch)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package intentsem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentintent "agent-intent/intent"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
)
|
||||
|
||||
// LightGenerator 用轻量模型实现 agentintent.CommandGenerator
|
||||
// (Rasa 式 LLM command generator:只把相关流程交给生成器)。
|
||||
// 提示词给出能力目录(spec id/描述/槽位键),要求模型输出严格的
|
||||
// 命令 JSON 数组;解析失败或模型不可达都返回错误——库侧约定生成器
|
||||
// 出错时识别保持 fallback(fail-closed)。
|
||||
type LightGenerator struct {
|
||||
// Stream 提供方边界(注入点;测试可脚本化)。
|
||||
Stream agentllm.StreamFunc
|
||||
// Model 是轻量档模型名。
|
||||
Model string
|
||||
// APIBase/APIKey 走 StreamOptions(客户端可按次覆盖)。
|
||||
APIBase string
|
||||
APIKey string
|
||||
// MaxTokens 上限;0 = 1024。
|
||||
MaxTokens int
|
||||
}
|
||||
|
||||
// 生成输出契约:action 与 agentintent.ActionRef 同形(kind/id)。
|
||||
type generatedCommand struct {
|
||||
Kind string `json:"kind"`
|
||||
Action generatedAction `json:"action,omitempty"`
|
||||
Slots map[string]string `json:"slots,omitempty"`
|
||||
}
|
||||
|
||||
type generatedAction struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// Generate 基于输入与能力目录产出命令列表。目录为空时不调用模型。
|
||||
func (g LightGenerator) Generate(ctx context.Context, input string, catalog []agentintent.CatalogSpec) ([]agentintent.Command, error) {
|
||||
if g.Stream == nil || len(catalog) == 0 || strings.TrimSpace(input) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
maxTok := g.MaxTokens
|
||||
if maxTok <= 0 {
|
||||
maxTok = 1024
|
||||
}
|
||||
msgs := []agentllm.Message{
|
||||
{Role: agentllm.RoleSystem, Content: generatorSystemPrompt(catalog)},
|
||||
{Role: agentllm.RoleUser, Content: input},
|
||||
}
|
||||
ch := g.Stream(ctx, g.Model, msgs, nil, agentllm.StreamOptions{
|
||||
MaxTokens: maxTok, APIBase: g.APIBase, APIKey: g.APIKey,
|
||||
})
|
||||
var sb strings.Builder
|
||||
for ev := range ch {
|
||||
switch ev.Kind {
|
||||
case agentllm.KindTextDelta:
|
||||
sb.WriteString(ev.Text)
|
||||
case agentllm.KindError:
|
||||
if ev.Err != nil {
|
||||
return nil, fmt.Errorf("intentsem: stream: %w", ev.Err)
|
||||
}
|
||||
return nil, fmt.Errorf("intentsem: stream: %s", ev.ErrorMessage)
|
||||
}
|
||||
}
|
||||
return parseCommands(sb.String())
|
||||
}
|
||||
|
||||
// parseCommands 从模型输出解析命令数组:剥代码围栏,取首个 [ 到末个 ],
|
||||
// 严格 JSON。任何形状不符都报错(由库侧 fail-closed 到 fallback)。
|
||||
func parseCommands(raw string) ([]agentintent.Command, error) {
|
||||
text := strings.TrimSpace(raw)
|
||||
text = strings.TrimPrefix(text, "```json")
|
||||
text = strings.TrimPrefix(text, "```")
|
||||
text = strings.TrimSuffix(text, "```")
|
||||
text = strings.TrimSpace(text)
|
||||
lo, hi := strings.Index(text, "["), strings.LastIndex(text, "]")
|
||||
if lo < 0 || hi <= lo {
|
||||
return nil, fmt.Errorf("intentsem: 输出不含命令数组")
|
||||
}
|
||||
var gen []generatedCommand
|
||||
if err := json.Unmarshal([]byte(text[lo:hi+1]), &gen); err != nil {
|
||||
return nil, fmt.Errorf("intentsem: 命令 JSON: %w", err)
|
||||
}
|
||||
out := make([]agentintent.Command, 0, len(gen))
|
||||
for _, c := range gen {
|
||||
out = append(out, agentintent.Command{
|
||||
Kind: agentintent.CommandKind(c.Kind),
|
||||
Action: agentintent.ActionRef{Kind: c.Action.Kind, ID: c.Action.ID},
|
||||
Slots: c.Slots,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// generatorSystemPrompt 渲染能力目录。只暴露 id/描述/槽位键——
|
||||
// 不暴露模式与示例(避免提示词注入借道示例文本进入慢通道)。
|
||||
func generatorSystemPrompt(catalog []agentintent.CatalogSpec) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("你是意图命令生成器。根据用户输入,从下面的能力目录选择并输出命令。\n")
|
||||
sb.WriteString("只输出一个 JSON 数组,不要输出任何其他文字。数组元素形如:\n")
|
||||
sb.WriteString(`[{"kind":"start","action":{"kind":"workflow","id":"<目录中的id>"},"slots":{"<槽位键>":"<值>"}}]` + "\n")
|
||||
sb.WriteString("规则:kind 取 start/set_slot/cancel/clarify/chitchat/handoff 之一;")
|
||||
sb.WriteString("start 必须带 action,action.kind 取 auto/command/workflow/blueprint/handler;")
|
||||
sb.WriteString("action.id 与 slots 的键必须来自目录;用户闲聊用 chitchat(不带 action);")
|
||||
sb.WriteString("无法判断时输出空数组 []。\n能力目录:\n")
|
||||
for _, spec := range catalog {
|
||||
sb.WriteString("- id=" + spec.ID)
|
||||
if spec.Description != "" {
|
||||
sb.WriteString(" 描述=" + spec.Description)
|
||||
}
|
||||
if spec.Handler != "" {
|
||||
sb.WriteString(" handler=" + spec.Handler)
|
||||
}
|
||||
if len(spec.Slots) > 0 {
|
||||
keys := make([]string, 0, len(spec.Slots))
|
||||
for _, sl := range spec.Slots {
|
||||
keys = append(keys, sl.Key)
|
||||
}
|
||||
sb.WriteString(" 槽位=[" + strings.Join(keys, ",") + "]")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package intentsem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentintent "agent-intent/intent"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
)
|
||||
|
||||
// fakeEmbedder 返回脚本化向量:texts[i] → vecs[i](循环取模)。
|
||||
type fakeEmbedder struct {
|
||||
vecs [][]float32
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
out := make([][]float32, len(texts))
|
||||
for i := range texts {
|
||||
out[i] = f.vecs[i%len(f.vecs)]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestEmbeddingScorerMaxCosine(t *testing.T) {
|
||||
// input 与 example2 同向(相似度 1),与 example1 正交(0)。
|
||||
s := EmbeddingScorer{Provider: fakeEmbedder{vecs: [][]float32{
|
||||
{1, 0}, {0, 1}, {2, 0},
|
||||
}}}
|
||||
got, err := s.Score(context.Background(), "输入", []string{"例一", "例二"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 1 {
|
||||
t.Fatalf("期望最大余弦 1,得到 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddingScorerErrorPropagates(t *testing.T) {
|
||||
s := EmbeddingScorer{Provider: fakeEmbedder{err: errors.New("boom")}}
|
||||
if _, err := s.Score(context.Background(), "x", []string{"y"}); err == nil {
|
||||
t.Fatal("期望错误上抛(库侧保持 Ambiguous)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddingScorerEmptyExamples(t *testing.T) {
|
||||
s := EmbeddingScorer{Provider: fakeEmbedder{vecs: [][]float32{{1}}}}
|
||||
got, err := s.Score(context.Background(), "x", nil)
|
||||
if err != nil || got != 0 {
|
||||
t.Fatalf("空示例应得 0 分无错误,得到 %v / %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// scriptedStream 回放固定文本增量后关闭通道。
|
||||
func scriptedStream(text string, err error) agentllm.StreamFunc {
|
||||
return func(_ context.Context, _ string, msgs []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
ch := make(chan agentllm.Event, 3)
|
||||
if err != nil {
|
||||
ch <- agentllm.Event{Kind: agentllm.KindError, Err: err}
|
||||
} else {
|
||||
ch <- agentllm.Event{Kind: agentllm.KindTextDelta, Text: text}
|
||||
}
|
||||
close(ch)
|
||||
return ch
|
||||
}
|
||||
}
|
||||
|
||||
func catalogFixture() []agentintent.CatalogSpec {
|
||||
return []agentintent.CatalogSpec{
|
||||
{ID: "deploy.run", Description: "部署应用", Slots: []agentintent.CatalogSlot{{Key: "env", Required: true}}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestLightGeneratorParsesCommands(t *testing.T) {
|
||||
g := LightGenerator{
|
||||
Stream: scriptedStream(" ```json\n[{\"kind\":\"start\",\"action\":{\"kind\":\"workflow\",\"id\":\"deploy.run\"},\"slots\":{\"env\":\"prod\"}}]\n``` ", nil),
|
||||
Model: "lite",
|
||||
}
|
||||
cmds, err := g.Generate(context.Background(), "把应用部署到生产", catalogFixture())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cmds) != 1 || cmds[0].Kind != agentintent.CommandStart {
|
||||
t.Fatalf("期望 1 条 start 命令,得到 %+v", cmds)
|
||||
}
|
||||
if cmds[0].Action.Kind != "workflow" || cmds[0].Action.ID != "deploy.run" {
|
||||
t.Fatalf("action 不符:%+v", cmds[0].Action)
|
||||
}
|
||||
if cmds[0].Slots["env"] != "prod" {
|
||||
t.Fatalf("槽位不符:%+v", cmds[0].Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLightGeneratorEmptyCatalogNoCall(t *testing.T) {
|
||||
called := false
|
||||
g := LightGenerator{Stream: func(_ context.Context, _ string, _ []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
called = true
|
||||
ch := make(chan agentllm.Event)
|
||||
close(ch)
|
||||
return ch
|
||||
}}
|
||||
cmds, err := g.Generate(context.Background(), "你好", nil)
|
||||
if err != nil || cmds != nil || called {
|
||||
t.Fatalf("空目录不应调用模型:cmds=%v err=%v called=%v", cmds, err, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLightGeneratorStreamError(t *testing.T) {
|
||||
g := LightGenerator{Stream: scriptedStream("", errors.New("dial refused"))}
|
||||
if _, err := g.Generate(context.Background(), "部署", catalogFixture()); err == nil ||
|
||||
!strings.Contains(err.Error(), "dial refused") {
|
||||
t.Fatalf("期望流错误上抛,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLightGeneratorBadJSON(t *testing.T) {
|
||||
g := LightGenerator{Stream: scriptedStream("我无法判断", nil)}
|
||||
if _, err := g.Generate(context.Background(), "部署", catalogFixture()); err == nil {
|
||||
t.Fatal("期望解析失败返回错误(库侧 fail-closed 回 fallback)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandsFencedAndChatty(t *testing.T) {
|
||||
raw := "好的,命令如下:\n```json\n[{\"kind\":\"chitchat\"}]\n```\n以上。"
|
||||
cmds, err := parseCommands(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cmds) != 1 || cmds[0].Kind != agentintent.CommandChitchat {
|
||||
t.Fatalf("期望 chitchat,得到 %+v", cmds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratorPromptContainsCatalogOnly(t *testing.T) {
|
||||
p := generatorSystemPrompt(catalogFixture())
|
||||
for _, want := range []string{"deploy.run", "部署应用", "env"} {
|
||||
if !strings.Contains(p, want) {
|
||||
t.Fatalf("提示词缺少 %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(p, "正例") || strings.Contains(p, "examples") {
|
||||
t.Fatal("提示词不应包含示例文本(注入面)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Package intentsem 慢通道实现(I2 增量三):把 agent-intent 的
|
||||
// SemanticScorer / CommandGenerator 端口接到宿主已有的向量与轻量模型
|
||||
// 能力上。两个实现都是可插拔的——装配层默认不接线(设置开关关),
|
||||
// 此时识别行为与 I1 完全一致。
|
||||
package intentsem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// EmbeddingScorer 用语义向量实现 agentintent.SemanticScorer:
|
||||
// 对 input 与每个 example 取向量,返回最大余弦相似度。
|
||||
// 任何错误都原样上抛——库侧约定 scorer 出错时识别保持 Ambiguous,
|
||||
// 不会使 Recognize 失败(fail-open 到规则层结论)。
|
||||
type EmbeddingScorer struct {
|
||||
Provider core.EmbeddingProvider
|
||||
}
|
||||
|
||||
// Score 返回 input 与 examples 的最高余弦相似度,取值 [0,1]。
|
||||
func (s EmbeddingScorer) Score(ctx context.Context, input string, examples []string) (float64, error) {
|
||||
if s.Provider == nil || len(examples) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
texts := make([]string, 0, len(examples)+1)
|
||||
texts = append(texts, input)
|
||||
texts = append(texts, examples...)
|
||||
vecs, err := s.Provider.Embed(ctx, texts)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(vecs) != len(texts) || len(vecs[0]) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
best := 0.0
|
||||
for _, v := range vecs[1:] {
|
||||
if sim := cosine(vecs[0], v); sim > best {
|
||||
best = sim
|
||||
}
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
// cosine 计算两个等长向量的余弦相似度;任一零向量或不等长返回 0。
|
||||
func cosine(a, b []float32) float64 {
|
||||
if len(a) == 0 || len(a) != len(b) {
|
||||
return 0
|
||||
}
|
||||
var dot, na, nb float64
|
||||
for i := range a {
|
||||
fa, fb := float64(a[i]), float64(b[i])
|
||||
dot += fa * fb
|
||||
na += fa * fa
|
||||
nb += fb * fb
|
||||
}
|
||||
if na == 0 || nb == 0 {
|
||||
return 0
|
||||
}
|
||||
return dot / (math.Sqrt(na) * math.Sqrt(nb))
|
||||
}
|
||||
+130
-17
@@ -1,8 +1,14 @@
|
||||
// Package pgbus 是 PG 档的事件跨实例扇出(阶段 3):
|
||||
// Package pgbus 是 PG 档的事件跨实例扇出(阶段 3 / 协作存储 U6 切片一):
|
||||
// 落盘事务提交后 NOTIFY agent_events '<conv>|<lastSeq>';每实例一个 LISTEN 连接,
|
||||
// 收到通知按 seq 从库取回事件并转发本地 bus(SSE 以 seq 去重,双投无害)。
|
||||
// 收到通知按会话游标从库分页追赶转发本地 bus(SSE 以 seq 去重,重投无害)。
|
||||
// 多实例部署契约:run 在其归属实例内存中执行,会话流量需按会话亲和路由
|
||||
// (网关/客户端择点);本包只解决“其他实例的订阅者”可见性。
|
||||
//
|
||||
// U6(KTD6)修复的两个既有缺口:
|
||||
// - 批内遗漏:通知只带末 seq,旧实现 EventAt 只取末条——批内中间事件对
|
||||
// 实时订阅者不可见。现按会话游标 EventsAfter 分页追赶,末 seq 即水位;
|
||||
// - 断线假重连:旧实现对原连接继续 WaitForNotification。现真正重建
|
||||
// 连接→LISTEN→按全部已知会话追赶,另有周期补查覆盖通知丢失。
|
||||
package pgbus
|
||||
|
||||
import (
|
||||
@@ -10,6 +16,7 @@ import (
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/bus"
|
||||
@@ -21,6 +28,12 @@ import (
|
||||
|
||||
const channel = "agent_events"
|
||||
|
||||
// catchUpPage 追赶分页大小(验收含 1000+ 事件分页可见)。
|
||||
const catchUpPage = 500
|
||||
|
||||
// rescueEvery 周期补查间隔:覆盖 NOTIFY 丢失与监听窗口前后竞争(KTD6)。
|
||||
const rescueEvery = 2 * time.Second
|
||||
|
||||
// Notifier 落盘后通知(任意连接池连接可用)。
|
||||
type Notifier struct{ st *store.Store }
|
||||
|
||||
@@ -39,53 +52,153 @@ func (n *Notifier) Notify(conversationID string, evs []core.StreamEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// Listener 每实例一个:LISTEN → 按 seq 取回 → 本地 bus 转发。
|
||||
// Listener 每实例一个:LISTEN → 按会话游标追赶 → 本地 bus 转发。
|
||||
type Listener struct {
|
||||
conn *pgx.Conn
|
||||
dsn string
|
||||
st *store.Store
|
||||
bus *bus.Bus
|
||||
done chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
cursor map[string]int64 // conv → 已转发水位(重启清零:SSE 侧重放兜底)
|
||||
}
|
||||
|
||||
// Start 建立监听连接并开始转发循环;返回后即可用,随 ctx 取消停机。
|
||||
func Start(ctx context.Context, dsn string, st *store.Store, b *bus.Bus) (*Listener, error) {
|
||||
conn, err := pgx.Connect(ctx, dsn)
|
||||
if err != nil {
|
||||
l := &Listener{dsn: dsn, st: st, bus: b, done: make(chan struct{}), cursor: map[string]int64{}}
|
||||
if err := l.connect(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := conn.Exec(ctx, "LISTEN "+channel); err != nil {
|
||||
conn.Close(ctx)
|
||||
return nil, err
|
||||
}
|
||||
l := &Listener{conn: conn, st: st, bus: b, done: make(chan struct{})}
|
||||
go l.loop(ctx)
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (l *Listener) connect(ctx context.Context) error {
|
||||
conn, err := pgx.Connect(ctx, l.dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := conn.Exec(ctx, "LISTEN "+channel); err != nil {
|
||||
conn.Close(ctx)
|
||||
return err
|
||||
}
|
||||
l.conn = conn
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Listener) loop(ctx context.Context) {
|
||||
defer close(l.done)
|
||||
// 周期补查独立 goroutine:通知丢失场景恰恰没有通知可触发——不能只在
|
||||
// 收到通知后顺带补查(KTD6)。
|
||||
go l.rescueLoop(ctx)
|
||||
for {
|
||||
n, err := l.conn.WaitForNotification(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return // 停机
|
||||
}
|
||||
log.Printf("[pgbus] wait: %v(1s 后重连)", err)
|
||||
time.Sleep(time.Second)
|
||||
// 真重连(U6):旧实现对原连接继续 Wait——断线后永不恢复。
|
||||
// 退避重建 → LISTEN → 按已知会话追赶(补断线窗口内落盘的事件)。
|
||||
log.Printf("[pgbus] wait: %v(重连中)", err)
|
||||
l.reconnect(ctx)
|
||||
continue
|
||||
}
|
||||
conv, seq, ok := parsePayload(n.Payload)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ev, err := l.st.EventAt(conv, seq)
|
||||
if err != nil {
|
||||
continue // 事件不可见(极早期窗口)——SSE 重放兜底
|
||||
}
|
||||
l.bus.Publish(ev)
|
||||
l.catchUp(conv, seq)
|
||||
}
|
||||
}
|
||||
|
||||
// rescueLoop 周期补查心跳:随 ctx 或主循环退出(done)停止。
|
||||
func (l *Listener) rescueLoop(ctx context.Context) {
|
||||
t := time.NewTicker(rescueEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-l.done:
|
||||
return
|
||||
case <-t.C:
|
||||
l.rescueKnown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reconnect 重建监听连接并立即追赶全部已知会话(有界退避重试)。
|
||||
func (l *Listener) reconnect(ctx context.Context) {
|
||||
_ = l.conn.Close(context.Background())
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
err := l.connect(cctx)
|
||||
cancel()
|
||||
if err == nil {
|
||||
l.rescueKnown()
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(time.Duration(attempt+1) * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// rescueKnown 周期补查:不经通知,按各会话游标继续分页追赶(覆盖 NOTIFY
|
||||
// 丢失、监听生效窗口前后竞争——KTD6「只有落库事件才是重放依据」)。
|
||||
// 调用方:重连成功后与 loop 的 rescue 心跳(消息稀疏时由 ticker 兜底)。
|
||||
func (l *Listener) rescueKnown() {
|
||||
l.mu.Lock()
|
||||
convs := make([]string, 0, len(l.cursor))
|
||||
for c := range l.cursor {
|
||||
convs = append(convs, c)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
for _, conv := range convs {
|
||||
l.catchUp(conv, 1<<62)
|
||||
}
|
||||
}
|
||||
|
||||
// catchUp 从会话游标之后分页取回并转发,直到取空或越过水位 toSeq。
|
||||
// 首见会话从 0 开始(SSE 端按 seq 去重,重投无害;这保证批内中间事件可见)。
|
||||
func (l *Listener) catchUp(conv string, toSeq int64) {
|
||||
l.mu.Lock()
|
||||
since := l.cursor[conv]
|
||||
l.mu.Unlock()
|
||||
for {
|
||||
evs, err := l.st.EventsAfter(conv, since, catchUpPage)
|
||||
if err != nil {
|
||||
log.Printf("[pgbus] catchup %s@%d: %v", conv, since, err)
|
||||
return
|
||||
}
|
||||
stop := false
|
||||
for _, ev := range evs {
|
||||
if ev.Seq > toSeq {
|
||||
stop = true
|
||||
break
|
||||
}
|
||||
l.bus.Publish(ev)
|
||||
since = ev.Seq
|
||||
}
|
||||
l.setCursor(conv, since)
|
||||
if stop || len(evs) < catchUpPage {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Listener) setCursor(conv string, seq int64) {
|
||||
l.mu.Lock()
|
||||
if seq > l.cursor[conv] {
|
||||
l.cursor[conv] = seq
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// parsePayload 拆 '<conv>|<seq>'(会话 ID 不含 '|')。
|
||||
func parsePayload(p string) (string, int64, bool) {
|
||||
i := strings.LastIndex(p, "|")
|
||||
if i < 0 {
|
||||
|
||||
@@ -29,6 +29,12 @@ func pgTestStore(t *testing.T) (*store.Store, string) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// pg_trgm 预装 public(幂等),理由见 store 包 openTestStore 注释:
|
||||
// 避免扩展落进用例 schema 后随 CASCADE 删除造成的并行竞态。
|
||||
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("preinstall pg_trgm: %v", err)
|
||||
}
|
||||
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
|
||||
admin.Close()
|
||||
t.Fatal(err)
|
||||
@@ -37,7 +43,7 @@ func pgTestStore(t *testing.T) (*store.Store, string) {
|
||||
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
|
||||
admin.Close()
|
||||
})
|
||||
full := dsn + "&search_path=" + schema
|
||||
full := store.AppendPGRuntimeParam(dsn, "search_path", schema+",public")
|
||||
st, err := store.OpenPostgres(full, filepath.Join(t.TempDir(), "artifacts"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -80,3 +86,157 @@ func TestNotifyListenerFanout(t *testing.T) {
|
||||
t.Fatal("no fanout within 3s")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- U6 切片一(KTD6):批内全可见、分页追赶、断线重连、通知丢失补查 ----
|
||||
|
||||
// drainUntil 从订阅通道收集事件直到谓词为真或超时;返回收到的全部事件。
|
||||
func drainUntil(ch <-chan core.StreamEvent, done func([]core.StreamEvent) bool, timeout time.Duration) []core.StreamEvent {
|
||||
var got []core.StreamEvent
|
||||
deadline := time.After(timeout)
|
||||
for {
|
||||
select {
|
||||
case e := <-ch:
|
||||
got = append(got, e)
|
||||
if done(got) {
|
||||
return got
|
||||
}
|
||||
case <-deadline:
|
||||
return got
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 一批 64 条事件(单条 NOTIFY 只带末 seq)对实时订阅者全部可见——
|
||||
// 旧实现 EventAt 只取末条,批内 1..63 全部遗漏。
|
||||
func TestBatchEventsAllVisible(t *testing.T) {
|
||||
st, dsn := pgTestStore(t)
|
||||
b2 := bus.New()
|
||||
listener, err := Start(context.Background(), dsn, st, b2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close(context.Background())
|
||||
ch, stop := b2.Subscribe("conv_batch")
|
||||
defer stop()
|
||||
|
||||
notifier := NewNotifier(st)
|
||||
evs := make([]core.StreamEvent, 64)
|
||||
for i := range evs {
|
||||
evs[i] = core.StreamEvent{ConversationID: "conv_batch", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)}
|
||||
}
|
||||
if _, err := st.AppendEvents("conv_batch", evs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier.Notify("conv_batch", evs)
|
||||
|
||||
got := drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= 64 }, 5*time.Second)
|
||||
if len(got) != 64 {
|
||||
t.Fatalf("批 64 条应全部可见,收到 %d(旧实现只末条可见)", len(got))
|
||||
}
|
||||
// 序号连续(首见会话从 0 追赶,SSE 去重语义下的完整性)。
|
||||
for i, e := range got {
|
||||
if e.Seq != int64(i+1) {
|
||||
t.Fatalf("序号不连续:got[%d].Seq=%d", i, e.Seq)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1200 条(跨 500/页 的三页)分页追赶全部可见。
|
||||
func TestPagedCatchUpBeyondOnePage(t *testing.T) {
|
||||
st, dsn := pgTestStore(t)
|
||||
b2 := bus.New()
|
||||
listener, err := Start(context.Background(), dsn, st, b2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close(context.Background())
|
||||
ch, stop := b2.Subscribe("conv_page")
|
||||
defer stop()
|
||||
|
||||
notifier := NewNotifier(st)
|
||||
const total = 1200
|
||||
evs := make([]core.StreamEvent, 0, total)
|
||||
for i := 0; i < total; i++ {
|
||||
evs = append(evs, core.StreamEvent{ConversationID: "conv_page", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)})
|
||||
}
|
||||
if _, err := st.AppendEvents("conv_page", evs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier.Notify("conv_page", evs)
|
||||
|
||||
got := drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= total }, 15*time.Second)
|
||||
if len(got) != total {
|
||||
t.Fatalf("1200 条应分页全部可见,收到 %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// 断线重连:底层连接被杀后,新落盘+通知的事件仍可达(真重连路径)。
|
||||
func TestListenerReconnectsAfterConnLoss(t *testing.T) {
|
||||
st, dsn := pgTestStore(t)
|
||||
b2 := bus.New()
|
||||
listener, err := Start(context.Background(), dsn, st, b2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close(context.Background())
|
||||
ch, stop := b2.Subscribe("conv_re")
|
||||
defer stop()
|
||||
|
||||
notifier := NewNotifier(st)
|
||||
first := []core.StreamEvent{{ConversationID: "conv_re", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)}}
|
||||
if _, err := st.AppendEvents("conv_re", first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier.Notify("conv_re", first)
|
||||
drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= 1 }, 3*time.Second)
|
||||
|
||||
// 杀掉监听连接(模拟网络断开;旧实现会对此连接永远 Wait 下去)。
|
||||
if err := listener.conn.Close(context.Background()); err != nil {
|
||||
t.Logf("close conn: %v", err)
|
||||
}
|
||||
// 断线窗口内落盘一批(重连后应经追赶补上)。
|
||||
during := []core.StreamEvent{{ConversationID: "conv_re", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)}}
|
||||
if _, err := st.AppendEvents("conv_re", during); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier.Notify("conv_re", during)
|
||||
|
||||
// 第二段 drain 从零计数:断线窗口内的一条事件到达即证明重连+追赶生效。
|
||||
got := drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= 1 }, 10*time.Second)
|
||||
if len(got) < 1 {
|
||||
t.Fatalf("重连后应补见断线窗口内事件:got=%d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// 通知丢失补查:事件落盘但**不**发 NOTIFY,周期补查应在 rescueEvery 内送达。
|
||||
func TestRescueCoversLostNotifications(t *testing.T) {
|
||||
st, dsn := pgTestStore(t)
|
||||
b2 := bus.New()
|
||||
listener, err := Start(context.Background(), dsn, st, b2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close(context.Background())
|
||||
ch, stop := b2.Subscribe("conv_lost")
|
||||
defer stop()
|
||||
|
||||
// 先用一次正常通知建立该会话的游标(补查只覆盖已知会话)。
|
||||
notifier := NewNotifier(st)
|
||||
seed := []core.StreamEvent{{ConversationID: "conv_lost", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)}}
|
||||
if _, err := st.AppendEvents("conv_lost", seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier.Notify("conv_lost", seed)
|
||||
drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= 1 }, 3*time.Second)
|
||||
|
||||
// 只落盘、不通知(模拟 NOTIFY 丢失)。
|
||||
lost := []core.StreamEvent{{ConversationID: "conv_lost", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)}}
|
||||
if _, err := st.AppendEvents("conv_lost", lost); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= 1 }, 3*rescueEvery+2*time.Second)
|
||||
if len(got) < 1 {
|
||||
t.Fatalf("周期补查应送达无通知事件:got=%d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func TestLoadDirAccountCheckedRejectsDisabledActorWithoutEffects(t *testing.T) {
|
||||
host, reg, dir := newHost(t)
|
||||
if err := host.st.CreateAccount("admin", "", "hash", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := host.st.CreateAccount("alice", "", "hash", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeExamplePlugin(t, dir, "example")
|
||||
if err := host.LoadDir()["example"]; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, ok := reg.Get("http_echo"); !ok {
|
||||
t.Fatal("fixture plugin was not registered")
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(dir, "example")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := filepath.Join(dir, "side")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mark := filepath.Join(t.TempDir(), "sidecar-ran")
|
||||
manifest := `{"id":"side","name":"Side","version":"1.0.0","apiVersion":"1.0","backend":{"mode":"sidecar","command":"/bin/touch","args":["` + mark + `"],"schemaVersion":1},"provides":{}}`
|
||||
if err := os.WriteFile(filepath.Join(root, "plugin.json"), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account, err := host.st.GetAccountByOwner("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := host.st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := host.LoadDirAccountChecked("alice", false); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled reload: %v", err)
|
||||
}
|
||||
if _, _, ok := reg.Get("http_echo"); !ok || len(host.List()) != 1 {
|
||||
t.Fatal("disabled reload changed the existing process registry")
|
||||
}
|
||||
if _, err := os.Stat(mark); !os.IsNotExist(err) {
|
||||
t.Fatalf("disabled reload started sidecar: %v", err)
|
||||
}
|
||||
plugins, err := host.st.ListPlugins()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plugins) != 1 || plugins[0].ID != "example" {
|
||||
t.Fatalf("disabled reload changed plugin rows: %+v", plugins)
|
||||
}
|
||||
if version, err := host.st.PluginSchemaVersion("side"); err != nil || version != 0 {
|
||||
t.Fatalf("disabled reload changed schema version: %d, %v", version, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDirAccountCheckedWritesForEnabledActor(t *testing.T) {
|
||||
host, _, dir := newHost(t)
|
||||
if err := host.st.CreateAccount("alice", "", "hash", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := filepath.Join(dir, "plain")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := `{"id":"plain","name":"Plain","version":"1.0.0","apiVersion":"1.0","backend":{"schemaVersion":2},"provides":{}}`
|
||||
if err := os.WriteFile(filepath.Join(root, "plugin.json"), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results, err := host.LoadDirAccountChecked("alice", false)
|
||||
if err != nil || results["plain"] != nil {
|
||||
t.Fatalf("enabled reload: %v, %+v", err, results)
|
||||
}
|
||||
plugins, err := host.st.ListPlugins()
|
||||
if err != nil || len(plugins) != 1 || plugins[0].ID != "plain" {
|
||||
t.Fatalf("plugin row: %+v, %v", plugins, err)
|
||||
}
|
||||
if version, err := host.st.PluginSchemaVersion("plain"); err != nil || version != 2 {
|
||||
t.Fatalf("schema version: %d, %v", version, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDirAccountCheckedRechecksBetweenPlugins(t *testing.T) {
|
||||
host, _, dir := newHost(t)
|
||||
if err := host.st.CreateAccount("admin", "", "hash", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := host.st.CreateAccount("alice", "", "hash", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account, err := host.st.GetAccountByOwner("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mark := filepath.Join(t.TempDir(), "b-ran")
|
||||
for id, manifest := range map[string]string{
|
||||
"a": `{"id":"a","name":"A","version":"1.0.0","apiVersion":"1.0","provides":{}}`,
|
||||
"b": `{"id":"b","name":"B","version":"1.0.0","apiVersion":"1.0","backend":{"mode":"sidecar","command":"/bin/touch","args":["` + mark + `"]},"provides":{}}`,
|
||||
} {
|
||||
root := filepath.Join(dir, id)
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "plugin.json"), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
callbackCount := 0
|
||||
host.OnCommands = func([]json.RawMessage) error {
|
||||
callbackCount++
|
||||
_, err := host.st.SetAccountEnabled("admin", account.ID, account.StateVersion, false)
|
||||
return err
|
||||
}
|
||||
results, err := host.LoadDirAccountChecked("alice", false)
|
||||
if !errors.Is(err, store.ErrAccountForbidden) || results["a"] != nil || callbackCount != 1 {
|
||||
t.Fatalf("reload after callback disable: %v, %+v, callbacks=%d", err, results, callbackCount)
|
||||
}
|
||||
plugins, err := host.st.ListPlugins()
|
||||
if err != nil || len(plugins) != 1 || plugins[0].ID != "a" {
|
||||
t.Fatalf("later plugin wrote after disable: %+v, %v", plugins, err)
|
||||
}
|
||||
if _, err := os.Stat(mark); !os.IsNotExist(err) {
|
||||
t.Fatalf("later sidecar started after disable: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// agentBannedKeys 是插件代理声明禁带的键:钩子、MCP 与权限模式归宿主与
|
||||
// 基座,插件画像只能声明身份与工具面(N1 WP1 验收条款)。
|
||||
var agentBannedKeys = map[string]bool{
|
||||
"hooks": true,
|
||||
"mcp": true,
|
||||
"permissionMode": true,
|
||||
}
|
||||
|
||||
// parseAgentAsset 解析 agents/ 资产:禁带键定位到字段、未知键拒绝、
|
||||
// id 必填。任一违规由调用方整批拒绝该插件(fail-closed)。
|
||||
func parseAgentAsset(raw []byte) (core.PluginAgentDecl, error) {
|
||||
var probe map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &probe); err != nil {
|
||||
return core.PluginAgentDecl{}, fmt.Errorf("agent json: %w", err)
|
||||
}
|
||||
for key := range probe {
|
||||
if agentBannedKeys[key] {
|
||||
return core.PluginAgentDecl{}, fmt.Errorf("插件代理不得声明 %s", key)
|
||||
}
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
var item core.PluginAgentDecl
|
||||
if err := dec.Decode(&item); err != nil {
|
||||
return core.PluginAgentDecl{}, fmt.Errorf("agent json: %w", err)
|
||||
}
|
||||
if item.ID == "" {
|
||||
return core.PluginAgentDecl{}, fmt.Errorf("agent missing id")
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// writeAgentPlugin 写一个带 agents/ 资产的插件目录。
|
||||
func writeAgentPlugin(t *testing.T, dir, id, agentJSON string) {
|
||||
t.Helper()
|
||||
root := filepath.Join(dir, id)
|
||||
os.MkdirAll(filepath.Join(root, "agents"), 0o755)
|
||||
write := func(rel, content string) {
|
||||
if err := os.WriteFile(filepath.Join(root, rel), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("plugin.json", `{"id":"`+id+`","name":"代理插件","version":"0.1.0","apiVersion":"`+core.PluginAPIVersion+`",
|
||||
"provides":{"agents":["reviewer"]}}`)
|
||||
write("agents/reviewer.json", agentJSON)
|
||||
}
|
||||
|
||||
func TestAgentAssetLoads(t *testing.T) {
|
||||
h, _, dir := newHost(t)
|
||||
writeAgentPlugin(t, dir, "agentplug", `{"id":"rdm-reviewer","title":"章节评审","instructions":"逐条核对来源","tools":["read_file","grep"]}`)
|
||||
if err := h.LoadDir()["agentplug"]; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, ld := range h.List() {
|
||||
if ld.Manifest.ID != "agentplug" {
|
||||
continue
|
||||
}
|
||||
if len(ld.Agents) != 1 || ld.Agents[0].ID != "rdm-reviewer" || len(ld.Agents[0].Tools) != 2 {
|
||||
t.Fatalf("agents = %+v", ld.Agents)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("plugin not loaded")
|
||||
}
|
||||
|
||||
// TestAgentAssetBansHostOnlyKeys:插件代理声明 hooks/mcp/permissionMode 时
|
||||
// 整批拒绝且错误定位到字段(N1 WP1 验收条款)。
|
||||
func TestAgentAssetBansHostOnlyKeys(t *testing.T) {
|
||||
for _, key := range []string{"hooks", "mcp", "permissionMode"} {
|
||||
h, _, dir := newHost(t)
|
||||
writeAgentPlugin(t, dir, "badplug", `{"id":"x","`+key+`":{}}`)
|
||||
err := h.LoadDir()["badplug"]
|
||||
if err == nil {
|
||||
t.Fatalf("%s accepted", key)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "不得声明 "+key) {
|
||||
t.Fatalf("%s error 未定位到字段: %v", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentAssetRejectsUnknownKeysAndMissingID(t *testing.T) {
|
||||
h, _, dir := newHost(t)
|
||||
writeAgentPlugin(t, dir, "unknownkey", `{"id":"x","bogus":1}`)
|
||||
if err := h.LoadDir()["unknownkey"]; err == nil {
|
||||
t.Fatal("unknown key accepted")
|
||||
}
|
||||
h2, _, dir2 := newHost(t)
|
||||
writeAgentPlugin(t, dir2, "noid", `{"title":"无 id"}`)
|
||||
if err := h2.LoadDir()["noid"]; err == nil {
|
||||
t.Fatal("missing id accepted")
|
||||
}
|
||||
}
|
||||
@@ -67,14 +67,55 @@ func New(st *store.Store, reg *tools.Registry, s *settings.Service, dir string)
|
||||
return &Host{st: st, registry: reg, settings: s, dir: dir, loaded: map[string]*Loaded{}, sidecars: map[string]*exec.Cmd{}}
|
||||
}
|
||||
|
||||
type loadAccountGuard struct {
|
||||
st *store.Store
|
||||
actor string
|
||||
allowImplicitDefault bool
|
||||
failed error
|
||||
}
|
||||
|
||||
func (g *loadAccountGuard) check() error {
|
||||
if g == nil {
|
||||
return nil // startup and existing fixture loader retain legacy behavior
|
||||
}
|
||||
if g.failed != nil {
|
||||
return g.failed
|
||||
}
|
||||
g.failed = g.st.CheckEffectiveAccountForExternalEffect(g.actor, g.allowImplicitDefault)
|
||||
return g.failed
|
||||
}
|
||||
|
||||
func (g *loadAccountGuard) record(err error) error {
|
||||
if g != nil && err != nil {
|
||||
g.failed = err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// LoadDir 全量装载目录(启动与热加载共用);返回每个插件的结果(失败不中断其他)。
|
||||
func (h *Host) LoadDir() map[string]error {
|
||||
results, _ := h.loadDir(nil)
|
||||
return results
|
||||
}
|
||||
|
||||
// LoadDirAccountChecked reloads on behalf of an already authorized actor.
|
||||
// Each Backend-owned DB write is fenced in its final transaction; process,
|
||||
// sidecar and filesystem effects get a fresh state check immediately before
|
||||
// execution. The caller must separately enforce plugin-management authority.
|
||||
func (h *Host) LoadDirAccountChecked(actor string, allowImplicitDefault bool) (map[string]error, error) {
|
||||
return h.loadDir(&loadAccountGuard{st: h.st, actor: actor, allowImplicitDefault: allowImplicitDefault})
|
||||
}
|
||||
|
||||
func (h *Host) loadDir(guard *loadAccountGuard) (map[string]error, error) {
|
||||
if err := guard.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(h.dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]error{}
|
||||
return map[string]error{}, nil
|
||||
}
|
||||
return map[string]error{"<dir>": err}
|
||||
return map[string]error{"<dir>": err}, nil
|
||||
}
|
||||
results := map[string]error{}
|
||||
seen := map[string]bool{}
|
||||
@@ -84,12 +125,19 @@ func (h *Host) LoadDir() map[string]error {
|
||||
}
|
||||
id := e.Name()
|
||||
seen[id] = true
|
||||
results[id] = h.loadOne(filepath.Join(h.dir, id))
|
||||
results[id] = h.loadOne(filepath.Join(h.dir, id), guard)
|
||||
if guard != nil && guard.failed != nil {
|
||||
return results, guard.failed
|
||||
}
|
||||
}
|
||||
// 移除已消失的插件(卸载语义)。
|
||||
h.mu.Lock()
|
||||
for id := range h.loaded {
|
||||
if !seen[id] {
|
||||
if err := guard.check(); err != nil {
|
||||
h.mu.Unlock()
|
||||
return results, err
|
||||
}
|
||||
delete(h.loaded, id)
|
||||
if cmd := h.sidecars[id]; cmd != nil && cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
@@ -104,25 +152,34 @@ func (h *Host) LoadDir() map[string]error {
|
||||
onIntents, onCommands, onWorkflows := h.OnIntents, h.OnCommands, h.OnWorkflows
|
||||
h.mu.Unlock()
|
||||
if onCommands != nil {
|
||||
if err := guard.check(); err != nil {
|
||||
return results, err
|
||||
}
|
||||
if err := onCommands(cmdOverlay); err != nil {
|
||||
results["<commands>"] = err
|
||||
}
|
||||
}
|
||||
if onIntents != nil {
|
||||
if err := guard.check(); err != nil {
|
||||
return results, err
|
||||
}
|
||||
if err := onIntents(overlay); err != nil {
|
||||
results["<intents>"] = err
|
||||
}
|
||||
}
|
||||
if onWorkflows != nil {
|
||||
if err := guard.check(); err != nil {
|
||||
return results, err
|
||||
}
|
||||
if err := onWorkflows(defs, scripts); err != nil {
|
||||
results["<workflows>"] = err
|
||||
}
|
||||
}
|
||||
return results
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// loadOne 装载单个插件;任何失败返回错误且不改变既有注册(fail-closed)。
|
||||
func (h *Host) loadOne(dir string) error {
|
||||
func (h *Host) loadOne(dir string, guard *loadAccountGuard) error {
|
||||
manifestPath := filepath.Join(dir, "plugin.json")
|
||||
raw, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
@@ -145,6 +202,9 @@ func (h *Host) loadOne(dir string) error {
|
||||
ld := &Loaded{Manifest: m, Dir: dir, Cards: map[string][]core.UINode{}, Prompts: map[string]string{}, Defaults: map[string]any{}, Scripts: map[string]string{}}
|
||||
|
||||
// 同 ID 重载:先注销旧注册(含旧版本工具),再装载新资产。
|
||||
if err := guard.check(); err != nil {
|
||||
return err
|
||||
}
|
||||
h.registry.UnregisterBySource(core.SourcePlugin, m.ID)
|
||||
|
||||
// tools/*.json:声明式 HTTP 工具(或含 parameters 的描述型工具)。
|
||||
@@ -159,6 +219,9 @@ func (h *Host) loadOne(dir string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("plugins: %s: tool %s: %w", m.ID, name, err)
|
||||
}
|
||||
if err := guard.check(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.registry.Register(desc, exec); err != nil {
|
||||
return fmt.Errorf("plugins: %s: register tool %s: %w", m.ID, name, err)
|
||||
}
|
||||
@@ -261,10 +324,13 @@ func (h *Host) loadOne(dir string) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.loadDeclared(dir, m, ld); err != nil {
|
||||
if err := h.loadDeclared(dir, m, ld, guard); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.startSidecar(m, dir); err != nil {
|
||||
if err := h.startSidecar(m, dir, guard); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := guard.check(); err != nil {
|
||||
return err
|
||||
}
|
||||
h.mu.Lock()
|
||||
@@ -274,18 +340,31 @@ func (h *Host) loadOne(dir string) error {
|
||||
defs, scripts := h.collectWorkflowsLocked()
|
||||
onIntents, onCommands, onWorkflows := h.OnIntents, h.OnCommands, h.OnWorkflows
|
||||
h.mu.Unlock()
|
||||
_ = h.st.UpsertPlugin(m.ID, dir, m)
|
||||
if guard == nil {
|
||||
_ = h.st.UpsertPlugin(m.ID, dir, m)
|
||||
} else if err := h.st.UpsertPluginAccountChecked(guard.actor, m.ID, dir, m, guard.allowImplicitDefault); err != nil {
|
||||
return guard.record(err)
|
||||
}
|
||||
if onCommands != nil {
|
||||
if err := guard.check(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := onCommands(cmdOverlay); err != nil {
|
||||
return fmt.Errorf("plugins: %s: commands rejected by command bus (kept previous): %w", m.ID, err)
|
||||
}
|
||||
}
|
||||
if onIntents != nil {
|
||||
if err := guard.check(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := onIntents(overlay); err != nil {
|
||||
return fmt.Errorf("plugins: %s: intents rejected by host gate (kept previous): %w", m.ID, err)
|
||||
}
|
||||
}
|
||||
if onWorkflows != nil {
|
||||
if err := guard.check(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := onWorkflows(defs, scripts); err != nil {
|
||||
return fmt.Errorf("plugins: %s: workflows rejected: %w", m.ID, err)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"agent-system-backend/internal/skills"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/tools"
|
||||
core "agent-system-core"
|
||||
@@ -20,7 +21,7 @@ func (h *Host) SetHostAPI(baseURL, token string) {
|
||||
h.hostAPI, h.hostToken = baseURL, token
|
||||
}
|
||||
|
||||
func (h *Host) startSidecar(m core.PluginManifest, dir string) error {
|
||||
func (h *Host) startSidecar(m core.PluginManifest, dir string, guard *loadAccountGuard) error {
|
||||
if m.Backend.Mode != "sidecar" || m.Backend.Command == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -37,10 +38,19 @@ func (h *Host) startSidecar(m core.PluginManifest, dir string) error {
|
||||
"AGENT_HOST_TOKEN="+h.hostToken,
|
||||
"AGENT_PLUGIN_ID="+m.ID,
|
||||
)
|
||||
if err := guard.check(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("plugins: %s: sidecar: %w", m.ID, err)
|
||||
}
|
||||
h.mu.Lock()
|
||||
if err := guard.check(); err != nil {
|
||||
h.mu.Unlock()
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
return err
|
||||
}
|
||||
if h.sidecars == nil {
|
||||
h.sidecars = map[string]*exec.Cmd{}
|
||||
}
|
||||
@@ -53,21 +63,31 @@ func (h *Host) startSidecar(m core.PluginManifest, dir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error {
|
||||
func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded, guard *loadAccountGuard) error {
|
||||
for _, name := range m.Provides.Skills {
|
||||
body, err := os.ReadFile(filepath.Join(dir, "skills", name+".md"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("plugins: %s: skill %s: %w", m.ID, name, err)
|
||||
}
|
||||
// 技能按开放规范校验(N2 WP4):不合规整批拒载,字段定位到键。
|
||||
if err := skills.ValidateForPlugin(m.ID, name, body); err != nil {
|
||||
return err
|
||||
}
|
||||
if ld.Skills == nil {
|
||||
ld.Skills = map[string]string{}
|
||||
}
|
||||
ld.Skills[name] = string(body)
|
||||
if h.skillsRoot != "" {
|
||||
dest := filepath.Join(h.skillsRoot, "plugin-"+m.ID, name)
|
||||
if err := guard.check(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dest, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := guard.check(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dest, "SKILL.md"), body, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -98,8 +118,12 @@ func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error
|
||||
ld.Parsers = append(ld.Parsers, item)
|
||||
}
|
||||
for _, name := range m.Provides.Agents {
|
||||
var item core.PluginAgentDecl
|
||||
if err := readAsset(dir, "agents", name, &item); err != nil {
|
||||
raw, err := readPluginFile(dir, filepath.Join("agents", name+".json"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("plugins: %s: agent %s: %w", m.ID, name, err)
|
||||
}
|
||||
item, err := parseAgentAsset(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plugins: %s: agent %s: %w", m.ID, name, err)
|
||||
}
|
||||
ld.Agents = append(ld.Agents, item)
|
||||
@@ -122,6 +146,9 @@ func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error
|
||||
ld.Pages = append(ld.Pages, item)
|
||||
}
|
||||
if m.Backend.SchemaVersion > 0 && h.st != nil {
|
||||
if guard != nil {
|
||||
return guard.record(h.st.SetPluginSchemaVersionAccountChecked(guard.actor, m.ID, m.Backend.SchemaVersion, guard.allowImplicitDefault))
|
||||
}
|
||||
cur, err := h.st.PluginSchemaVersion(m.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -3,6 +3,7 @@ package plugins
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -33,7 +34,7 @@ func TestV2AssetsAndNetwork(t *testing.T) {
|
||||
"provides":{"tools":["echo"],"skills":["note"],"mcpServers":["local"],"knowledge":["regs"],"parsers":["pdf"],"agents":["writer"],"channels":["feishu"],"pages":["home"]}
|
||||
}`)
|
||||
write("tools/echo.json", `{"name":"v2_echo","description":"e","parameters":{"type":"object"},"http":{"method":"GET","url":"https://api.example.com/x"}}`)
|
||||
write("skills/note.md", "# note\n")
|
||||
write("skills/note.md", "---\nname: note\ndescription: 笔记技能\n---\n\n# note\n")
|
||||
write("mcp/local.json", `{"name":"local","command":"echo"}`)
|
||||
write("knowledge/regs.json", `{"id":"regs","title":"法规"}`)
|
||||
write("parsers/pdf.json", `{"id":"pdf","title":"PDF","mime":"application/pdf"}`)
|
||||
@@ -95,3 +96,33 @@ func TestSidecarTouch(t *testing.T) {
|
||||
}
|
||||
t.Fatal("sidecar did not run")
|
||||
}
|
||||
|
||||
// N2 WP4:不合规插件技能整批拒载,错误带插件上下文与字段定位。
|
||||
func TestV2InvalidSkillRejected(t *testing.T) {
|
||||
host, _, dir := newHost(t)
|
||||
root := filepath.Join(dir, "badskill")
|
||||
if err := os.MkdirAll(filepath.Join(root, "skills"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write := func(rel, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(root, rel), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("plugin.json", `{
|
||||
"id":"badskill","name":"Bad","version":"0.1.0","apiVersion":"`+core.PluginAPIVersionExperimental+`",
|
||||
"backend":{"schemaVersion":1},
|
||||
"provides":{"skills":["note"]}
|
||||
}`)
|
||||
// 缺 name(开放规范必填)。
|
||||
write("skills/note.md", "---\ndescription: 没有名字\n---\n正文\n")
|
||||
err := host.LoadDir()["badskill"]
|
||||
if err == nil {
|
||||
t.Fatal("不合规技能必须拒载")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "plugins: badskill: skill note:") ||
|
||||
!strings.Contains(err.Error(), "name") {
|
||||
t.Fatalf("错误须带插件上下文与字段定位:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,23 @@ func (s *Service) Save(owner string, p core.AgentProfile) error {
|
||||
|
||||
func (s *Service) Delete(owner, id string) error { return s.st.DeleteProfile(owner, id) }
|
||||
|
||||
// SaveAccountChecked is the user-facing write path. Builtin profiles remain
|
||||
// read-only and the Store binds the write to the actor's effective account.
|
||||
func (s *Service) SaveAccountChecked(actor string, p core.AgentProfile, allowImplicitDefault bool) error {
|
||||
if p.ID == DefaultProfileID || p.Builtin {
|
||||
return errReadOnly("profiles: builtin profile is read-only; duplicate and modify")
|
||||
}
|
||||
if p.Tools.Allow == nil {
|
||||
p.Tools.Allow = []string{"*"}
|
||||
}
|
||||
p.Owner = actor
|
||||
return s.st.SaveProfileAccountChecked(actor, p, allowImplicitDefault)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteAccountChecked(actor, id string, allowImplicitDefault bool) error {
|
||||
return s.st.DeleteProfileAccountChecked(actor, id, allowImplicitDefault)
|
||||
}
|
||||
|
||||
type errReadOnly string
|
||||
|
||||
func (e errReadOnly) Error() string { return string(e) }
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func TestProfileServiceAccountCheckedWrites(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.CreateAccount("admin", "", "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("alice", "", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := New(st)
|
||||
if err := svc.EnsureDefault(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.SaveAccountChecked("alice", core.AgentProfile{ID: "profile-a", Name: "A"}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.GetProfile("alice", "profile-a")
|
||||
if err != nil || got.Owner != "alice" || got.Builtin || len(got.Tools.Allow) != 1 || got.Tools.Allow[0] != "*" {
|
||||
t.Fatalf("service did not normalize actor profile: %+v %v", got, err)
|
||||
}
|
||||
if err := svc.SaveAccountChecked("alice", core.AgentProfile{ID: DefaultProfileID}, false); err == nil {
|
||||
t.Fatal("service edited builtin default")
|
||||
}
|
||||
if err := svc.SaveAccountChecked("alice", core.AgentProfile{ID: "forged", Builtin: true}, false); err == nil {
|
||||
t.Fatal("service accepted forged builtin profile")
|
||||
}
|
||||
account, err := st.GetAccountByOwner("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.DeleteAccountChecked("alice", "profile-a", false); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled actor deleted profile: %v", err)
|
||||
}
|
||||
if err := svc.SaveAccountChecked("alice", core.AgentProfile{ID: "profile-a", Name: "late"}, false); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled actor saved profile: %v", err)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user