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 | ||
|
|
9754531cc9 | ||
|
|
545a75b54e | ||
|
|
f13e8b1ab7 |
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+191
-68
@@ -43,9 +43,11 @@ func main() {
|
||||
os.Exit(runUser(os.Args[2:]))
|
||||
case "identity":
|
||||
os.Exit(runIdentity(os.Args[2:]))
|
||||
case "capabilities":
|
||||
os.Exit(runCapabilities(os.Args[2:]))
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "agent-system: 未知子命令 %s\n", os.Args[1])
|
||||
fmt.Fprintln(os.Stderr, "用法:agent-system <serve|version|identity|user|apikey|config> [参数]")
|
||||
fmt.Fprintln(os.Stderr, "用法:agent-system <serve|version|identity|user|apikey|config|capabilities> [参数]")
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
@@ -194,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)
|
||||
@@ -238,12 +265,11 @@ func runAPIKey(args []string) int {
|
||||
}
|
||||
}
|
||||
|
||||
// deliverCredential 凭据单次交付(KTD14):--out 时写 0600 JSON 文件,stdout 只报
|
||||
// 属主/用途/文件路径不含明文;缺省沿用 stdout 一次输出。文件含 expiresAt 便于交接。
|
||||
// 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 == "" {
|
||||
fmt.Println(plaintext)
|
||||
return nil
|
||||
return fmt.Errorf("protected credential delivery path required")
|
||||
}
|
||||
payload := map[string]any{"kind": kind, "owner": owner, "secret": plaintext}
|
||||
if expiresAt != nil {
|
||||
@@ -253,37 +279,162 @@ func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) er
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(outPath, buf, 0o600); err != nil {
|
||||
if err := writeExclusiveSecret(outPath, string(buf)); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", outPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runUser:create / reset-password / list / issue-login-key / revoke-login-key / bootstrap-admin。
|
||||
// 默认 create 无初始密码并签发激活 Key;--generate-password 兼容旧随机密码路径。
|
||||
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|issue-login-key|revoke-login-key|bootstrap-admin> [flags]")
|
||||
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", "", "密码(create 空=激活 Key;加 --generate-password 则随机)")
|
||||
password := fs.String("password", "", "密码(已弃用;请用 --password-stdin)")
|
||||
passwordStdin := fs.Bool("password-stdin", false, "从 stdin 读取密码")
|
||||
outFile := fs.String("out-file", "", "把明文凭据独占写入该文件(0600)")
|
||||
instanceID := fs.String("instance-id", "", "严格核验库内服务身份")
|
||||
admin := fs.Bool("admin", false, "管理员")
|
||||
role := fs.String("role", "user", "角色 user|admin")
|
||||
openExisting := fs.Bool("open-existing", false, "只打开既有存储")
|
||||
generatePassword := fs.Bool("generate-password", false, "create 时生成随机初始密码")
|
||||
purpose := fs.String("purpose", "recover", "issue-login-key:activate|recover")
|
||||
ttlMin := fs.Int("ttl-minutes", 30, "登录 Key 有效分钟(5–120)")
|
||||
revokeAll := fs.Bool("revoke-all-credentials", false, "签发时撤销全部长期 API Key 与会话")
|
||||
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,代替 stdout 明文;非交互推荐)")
|
||||
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 == "" {
|
||||
@@ -299,41 +450,35 @@ func runUser(args []string) int {
|
||||
fmt.Fprintf(os.Stderr, "user create: 未知角色 %q\n", *role)
|
||||
return 2
|
||||
}
|
||||
pw, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword)
|
||||
pw, opID, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user create:", err)
|
||||
return 1
|
||||
}
|
||||
if err := deliverCredential(*out, "login-key", *owner, pw, nil); err != nil {
|
||||
if err := delivery.deliver("login-key", *owner, pw, nil); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user create:", err)
|
||||
return 1
|
||||
}
|
||||
if *out == "" {
|
||||
if *password == "" && !*generatePassword {
|
||||
fmt.Fprintln(os.Stderr, "一次性激活 Key 仅此一次输出;兑换后设置密码。")
|
||||
} else if *password == "" {
|
||||
fmt.Fprintln(os.Stderr, "初始密码仅此一次输出;该用户首次登录须修改密码。")
|
||||
}
|
||||
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, *openExisting)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user reset-password:", err)
|
||||
return 1
|
||||
}
|
||||
if err := deliverCredential(*out, "password", *owner, pw, nil); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user reset-password:", err)
|
||||
return 1
|
||||
}
|
||||
if *out == "" && *password == "" {
|
||||
fmt.Fprintln(os.Stderr, "新密码仅此一次输出;该用户下次登录须修改密码(其余会话已吊销)。")
|
||||
}
|
||||
return 0
|
||||
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED")
|
||||
return 2
|
||||
case "list":
|
||||
users, err := app.ListUserAccounts(*dsn, *data, *openExisting)
|
||||
if err != nil {
|
||||
@@ -344,27 +489,8 @@ func runUser(args []string) int {
|
||||
fmt.Println(string(out))
|
||||
return 0
|
||||
case "issue-login-key":
|
||||
if *owner == "" {
|
||||
fmt.Fprintln(os.Stderr, "user issue-login-key: --owner 必填")
|
||||
return 2
|
||||
}
|
||||
if *purpose != store.LoginKeyPurposeActivate && *purpose != store.LoginKeyPurposeRecover {
|
||||
fmt.Fprintf(os.Stderr, "user issue-login-key: 无效 purpose %q\n", *purpose)
|
||||
return 2
|
||||
}
|
||||
plain, err := app.IssueUserLoginKey(*dsn, *data, *owner, *purpose, *openExisting, *revokeAll, time.Duration(*ttlMin)*time.Minute)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user issue-login-key:", err)
|
||||
return 1
|
||||
}
|
||||
if err := deliverCredential(*out, "login-key", *owner, plain, time.Now().Add(time.Duration(*ttlMin)*time.Minute).UTC()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user issue-login-key:", err)
|
||||
return 1
|
||||
}
|
||||
if *out == "" {
|
||||
fmt.Fprintln(os.Stderr, "一次性登录 Key 仅此一次输出。")
|
||||
}
|
||||
return 0
|
||||
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED")
|
||||
return 2
|
||||
case "revoke-login-key":
|
||||
if *keyID == "" {
|
||||
fmt.Fprintln(os.Stderr, "user revoke-login-key: --id 必填")
|
||||
@@ -386,13 +512,10 @@ func runUser(args []string) int {
|
||||
fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err)
|
||||
return 1
|
||||
}
|
||||
if err := deliverCredential(*out, "bootstrap-admin", *owner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil {
|
||||
if err := delivery.deliver("bootstrap-admin", *owner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err)
|
||||
return 1
|
||||
}
|
||||
if *out == "" {
|
||||
fmt.Fprintln(os.Stderr, "首位管理员激活 Key 仅此一次输出。")
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "user: 未知子命令", sub)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,18 @@ 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)
|
||||
@@ -61,6 +73,16 @@ func TestMigrateEndToEndAndIdempotent(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -281,7 +281,7 @@ func cmdBackup(args []string) error {
|
||||
// copyOrder:按外键依赖排序的业务表全集(projects 最先,引用方在后)。
|
||||
// admission_leases 是运行态执行许可(迁移无意义),明确排除。
|
||||
var copyOrder = []string{
|
||||
"projects", "users", "profiles", "settings", "plugins",
|
||||
"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",
|
||||
@@ -297,6 +297,7 @@ var copyOrder = []string{
|
||||
"agent_system_identity",
|
||||
"account_events",
|
||||
"redeem_attempts",
|
||||
"recovery_proposals", "recovery_proposal_proofs",
|
||||
}
|
||||
|
||||
const controlDDL = `
|
||||
@@ -450,6 +451,11 @@ func guardIdentity(db *sql.DB, migrationID, snapID string, srcVersion int) error
|
||||
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") {
|
||||
@@ -707,6 +713,14 @@ func copyRows(db *sql.DB, src *sql.DB, table string, cols []string) error {
|
||||
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
|
||||
@@ -784,7 +798,8 @@ func advanceSequences(db *sql.DB, table string) error {
|
||||
// checkOwners:业务行引用的 owner 必须存在于 users(单租户默认 ”/'local'
|
||||
// 除外)。未知 owner 阻塞迁移(U4/R7:不静默带过未知归属)。
|
||||
func checkOwners(db *sql.DB, src *sql.DB) error {
|
||||
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "project_event_shares", "command_audit"}
|
||||
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards",
|
||||
"project_event_shares", "command_audit", "sessions", "api_keys", "login_keys", "account_events", "recovery_proposals"}
|
||||
for _, t := range ownerTables {
|
||||
if !sqliteHasTable(src, t) {
|
||||
continue
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,20 @@ func TestInprocHostListAccounts(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
h := inprocHost{caps: hostcaps{st: st}}
|
||||
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)
|
||||
@@ -50,6 +59,12 @@ func TestInprocHostListAccounts(t *testing.T) {
|
||||
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——账号/姓名子串
|
||||
@@ -65,7 +80,7 @@ func TestInprocHostSearchAccounts(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("alice", "王小明", hash, false); err != nil {
|
||||
if err := st.CreateAccount("alice", "王小明", hash, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("bob", "李大明", hash, false); err != nil {
|
||||
@@ -74,7 +89,10 @@ func TestInprocHostSearchAccounts(t *testing.T) {
|
||||
if err := st.CreateAccount("carol", "", hash, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := inprocHost{caps: hostcaps{st: st}}
|
||||
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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+169
-111
@@ -450,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()
|
||||
@@ -524,14 +527,25 @@ func Run(opt Options) error {
|
||||
// fs 预留能力沙箱根:<DataDir>/workspaces/<owner>/<conversationID>/。
|
||||
mgr.SetWorkspaceRoot(filepath.Join(opt.DataDir, "workspaces"))
|
||||
// 中断子代理续跑(N2 WP5):依赖转录目录(workspaceRoot)与派生请求。
|
||||
mgr.ResumeInterruptedChildren()
|
||||
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.RecoverPendingEvents(context.Background()); err == nil && n > 0 {
|
||||
if n, err := surfaceSvc.RecoverPendingEventsAccountChecked(context.Background(), !authOn); err == nil && n > 0 {
|
||||
log.Printf("surface: recovered %d pending events", n)
|
||||
}
|
||||
}()
|
||||
@@ -554,6 +568,17 @@ func Run(opt Options) error {
|
||||
|
||||
// 命令总线(提案 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) {
|
||||
@@ -597,7 +622,21 @@ func Run(opt Options) error {
|
||||
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
|
||||
authzSlot := &projectAuthz{}
|
||||
projectSlot := &projectLifecycleSlot{}
|
||||
caps := hostcaps{projects: projectSlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc}
|
||||
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 {
|
||||
@@ -633,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)
|
||||
@@ -693,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>)")
|
||||
@@ -738,55 +779,69 @@ func Run(opt Options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateUserAccount CLI 建号。
|
||||
// password 空且 generatePassword=false:无初始密码,同事务签发 activate Key,返回明文 Key。
|
||||
// generatePassword=true 或显式 password:兼容旧路径(随机/给定密码 + must_change)。
|
||||
func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, error) {
|
||||
var out string
|
||||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
if password != "" || generatePassword {
|
||||
pw := password
|
||||
if pw == "" {
|
||||
pw = server.RandomPassword()
|
||||
}
|
||||
hash, err := store.HashPassword(pw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.CreateAccount(owner, name, hash, isAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
out = pw
|
||||
return nil
|
||||
func assertLocalAccountOwner(st *store.Store, storageDSN, dataDir string) error {
|
||||
if storageDSN == "" {
|
||||
storageDSN = os.Getenv("AGENT_SYSTEM_DSN")
|
||||
}
|
||||
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
|
||||
}
|
||||
if err := st.CreateAccount(owner, name, "", isAdmin); err != nil {
|
||||
return nil
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(dataDir, "agent-system.db"))
|
||||
if 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 nil
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
var out store.AccountOutcome
|
||||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
plain, _, err := st.IssueLoginKey(owner, store.LoginKeyPurposeActivate, "cli", 0)
|
||||
_, 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 = plain
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// IssueUserLoginKey CLI 签发激活/恢复 Key。
|
||||
func IssueUserLoginKey(storageDSN, dataDir, owner, purpose string, openExisting, revokeAllCreds bool, ttl time.Duration) (string, error) {
|
||||
var plain string
|
||||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
if revokeAllCreds {
|
||||
if err := st.RevokeAllAPIKeys(owner); err != nil {
|
||||
return err
|
||||
}
|
||||
st.DeleteUserSessions(owner, "")
|
||||
}
|
||||
p, _, err := st.IssueLoginKey(owner, purpose, "cli", ttl)
|
||||
plain = p
|
||||
out, err := st.InitializePendingAccountWithAudit(userID, expectedStateVersion, expectedGeneration, accountInstanceID())
|
||||
plain, owner = out.Plain, account.Owner
|
||||
return err
|
||||
})
|
||||
return plain, err
|
||||
return plain, owner, err
|
||||
}
|
||||
|
||||
// RevokeUserLoginKey CLI 撤销未使用登录 Key。
|
||||
@@ -801,8 +856,13 @@ func RevokeUserLoginKey(storageDSN, dataDir, id string, openExisting bool) error
|
||||
func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (string, error) {
|
||||
var plain string
|
||||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
p, err := st.BootstrapFirstAdmin(owner, owner, "cli-bootstrap")
|
||||
plain = p
|
||||
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
|
||||
@@ -811,19 +871,7 @@ func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (strin
|
||||
// ResetUserPassword CLI 重置密码:单事务写 must_change=1、bump 认证版本、
|
||||
// 作废未用登录 Key、吊销全部会话并审计。
|
||||
func ResetUserPassword(storageDSN, dataDir, owner, password string, openExisting bool) (string, error) {
|
||||
if password == "" {
|
||||
password = server.RandomPassword()
|
||||
}
|
||||
hash, err := store.HashPassword(password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
return st.AdminResetUserPassword(owner, hash, "cli", "cli")
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return password, nil
|
||||
return "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: administrators cannot set another user's password")
|
||||
}
|
||||
|
||||
// ListUserAccounts CLI 用户列表。
|
||||
@@ -851,83 +899,85 @@ func withIdentityStore(storageDSN, dataDir string, fn func(*store.Store) error)
|
||||
return fn(st)
|
||||
}
|
||||
|
||||
func withStoreMode(storageDSN, dataDir string, openExisting bool, fn func(*store.Store) error) error {
|
||||
func withStoreMode(storageDSN, dataDir string, _ bool, fn func(*store.Store) error) error {
|
||||
var st *store.Store
|
||||
var err error
|
||||
// DSN 等价安全输入(KTD14):argv 缺省时回落环境变量——凭据不落 shell history。
|
||||
if storageDSN == "" {
|
||||
storageDSN = os.Getenv("AGENT_SYSTEM_DSN")
|
||||
}
|
||||
// Account maintenance never initializes or migrates storage; the retained
|
||||
// CLI compatibility flag cannot opt this operation into service initialization.
|
||||
if storageDSN != "" {
|
||||
if openExisting {
|
||||
st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
} else {
|
||||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
}
|
||||
st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
} else {
|
||||
dbPath := filepath.Join(dataDir, "agent-system.db")
|
||||
if openExisting {
|
||||
st, err = store.OpenExistingSQLite(dbPath)
|
||||
} else {
|
||||
st, err = store.OpenSQLite(dbPath)
|
||||
}
|
||||
st, err = store.OpenExistingSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
if id := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")); id != "" {
|
||||
dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF"))
|
||||
if dbref == "" {
|
||||
if storageDSN != "" {
|
||||
dbref = "pg"
|
||||
} else {
|
||||
dbref = "sqlite"
|
||||
}
|
||||
}
|
||||
if err := st.VerifySystemIdentity(id, dbref); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fn(st)
|
||||
}
|
||||
|
||||
// CreateAPIKey 是 apikey 子命令的实现:在指定存储档上为 owner 创建密钥(明文仅打印一次)。
|
||||
func CreateAPIKey(storageDSN, dataDir, owner, name string) (string, error) {
|
||||
var st *store.Store
|
||||
var err error
|
||||
if storageDSN != "" {
|
||||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
} else {
|
||||
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer st.Close()
|
||||
return st.CreateAPIKey(owner, name)
|
||||
var plain string
|
||||
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
|
||||
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 引导)。
|
||||
@@ -956,6 +1006,14 @@ func VerifySystemIdentity(storageDSN, dataDir, instanceID, dbref string) (int, e
|
||||
return schemaVersion, err
|
||||
}
|
||||
|
||||
func cliActor() string {
|
||||
return fmt.Sprintf("%d", os.Getuid())
|
||||
}
|
||||
|
||||
func accountInstanceID() string {
|
||||
return strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID"))
|
||||
}
|
||||
|
||||
// cachedInt 带短 TTL 的整型缓存读(限流值热读取免打库)。
|
||||
func cachedInt(ttl time.Duration, fn func() int) func() int {
|
||||
var mu sync.Mutex
|
||||
|
||||
@@ -64,7 +64,7 @@ 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, actor: req.Actor}
|
||||
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,
|
||||
@@ -116,6 +116,17 @@ func (r compiledRegistry) RegisterResourceAuthorizer(extension.ResourceAuthorize
|
||||
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 未覆盖该方法——进程内插件注册适配器会命中声明式分支的
|
||||
@@ -150,6 +161,14 @@ type recordRegistry struct {
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ func newExt9Host(t *testing.T) (interface {
|
||||
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"}
|
||||
|
||||
+111
-40
@@ -21,12 +21,15 @@ import (
|
||||
// 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内
|
||||
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
|
||||
type hostcaps struct {
|
||||
projects *projectLifecycleSlot
|
||||
st *store.Store
|
||||
pipe *rag.Pipeline
|
||||
set *settings.Service
|
||||
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
|
||||
surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝)
|
||||
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。
|
||||
@@ -109,6 +112,11 @@ type sharedRetriever interface {
|
||||
}
|
||||
|
||||
func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) {
|
||||
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)
|
||||
@@ -143,10 +151,23 @@ 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)
|
||||
}
|
||||
|
||||
@@ -176,6 +197,13 @@ type inprocHost struct {
|
||||
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 }
|
||||
|
||||
@@ -190,14 +218,26 @@ func (h inprocHost) IsAdmin() bool {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return state.IsAdmin
|
||||
return state.Enabled && state.IsAdmin
|
||||
}
|
||||
|
||||
// ListAccounts 实现 EXT-19 AccountLister(最小账号目录):仅授权管理读面用。
|
||||
// 有效态=账号存在且非停用(当前 users 表无停用位——must_change_password 不影响
|
||||
// 有效性;后续加停用位在此映射)。P3(2026-10-13):携带 Name 供同名区分。
|
||||
// 游标=owner 升序偏移。
|
||||
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
|
||||
}
|
||||
@@ -207,7 +247,7 @@ func (h inprocHost) ListAccounts(cursor string, limit int) ([]extension.AccountE
|
||||
}
|
||||
entries := make([]extension.AccountEntry, 0, len(users))
|
||||
for _, u := range users {
|
||||
entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: true})
|
||||
entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: u.Enabled})
|
||||
}
|
||||
// 排序+游标(owner 升序;cursor 为上一页末 owner,空=首页)。
|
||||
for i := 0; i < len(entries); i++ {
|
||||
@@ -238,6 +278,9 @@ func (h inprocHost) ListAccounts(cursor string, limit int) ([]extension.AccountE
|
||||
// 姓名子串检索(大小写不敏感;空 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
|
||||
}
|
||||
@@ -253,7 +296,7 @@ func (h inprocHost) SearchAccounts(query string, limit int) ([]extension.Account
|
||||
!strings.Contains(strings.ToLower(u.Name), q) {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: true})
|
||||
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 {
|
||||
@@ -263,25 +306,40 @@ func (h inprocHost) SearchAccounts(query string, limit int) ([]extension.Account
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -303,6 +361,9 @@ func (h inprocHost) knowledgeSource(source string) error {
|
||||
// "plugin:<id>";embed=false 只写全文行,embed=true 走 IndexChunks 建向量
|
||||
// (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
|
||||
}
|
||||
@@ -317,20 +378,21 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
type publicationRef struct{ projectID, ref string }
|
||||
refs := map[publicationRef]bool{}
|
||||
for i := range docs {
|
||||
docs[i].Owner = core.ID(h.owner)
|
||||
if docs[i].Source == "" {
|
||||
@@ -339,39 +401,33 @@ func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeD
|
||||
if err := h.knowledgeSource(docs[i].Source); err != nil {
|
||||
return err
|
||||
}
|
||||
if docs[i].Ref != "" {
|
||||
refs[publicationRef{docs[i].ProjectID, docs[i].Ref}] = true
|
||||
}
|
||||
}
|
||||
scoped := h.caps.projects != nil && h.caps.projects.active()
|
||||
for ref := range refs {
|
||||
var err error
|
||||
if scoped {
|
||||
err = h.caps.st.DeletePluginProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref, h.pluginID)
|
||||
} else {
|
||||
err = h.caps.st.DeleteProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if embed {
|
||||
return h.caps.pipe.PublishChunksAccountChecked(ctx, core.ID(h.owner), h.pluginID, scoped, docs, h.caps.allowImplicitLocal)
|
||||
}
|
||||
|
||||
return h.KnowledgeWrite(ctx, docs, embed)
|
||||
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.DeleteKnowledgeBySource(ctx, h.owner, sourcePrefix)
|
||||
_, 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
|
||||
}
|
||||
@@ -379,27 +435,42 @@ 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
|
||||
}
|
||||
@@ -411,11 +482,11 @@ func (h inprocHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRe
|
||||
if err != nil {
|
||||
return core.SurfaceSnapshot{}, err
|
||||
}
|
||||
return svc.Submit(ctx, h.owner, h.pluginID, surfacesvc.SubmitRequest{
|
||||
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) {
|
||||
|
||||
@@ -78,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",
|
||||
})
|
||||
@@ -157,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",
|
||||
|
||||
@@ -16,6 +16,11 @@ func TestHostKnowledgeV2AndProjectReplacement(t *testing.T) {
|
||||
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}}
|
||||
@@ -76,6 +81,9 @@ func TestRegisteredProjectHostRejectsForeignKnowledgeSource(t *testing.T) {
|
||||
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 {
|
||||
@@ -110,6 +118,9 @@ func TestProjectPublishPreservesForeignPluginAndScope(t *testing.T) {
|
||||
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 {
|
||||
@@ -150,6 +161,9 @@ func TestUnregisteredPublishRetainsLegacyRefScope(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func (slot *projectLifecycleSlot) wire(srv *server.Server, caps hostcaps) {
|
||||
}
|
||||
holder := slot.holder
|
||||
srv.SetProjectLifecycle(*slot.provider, func(owner string) extension.Host {
|
||||
return inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder}
|
||||
return requestBoundHost{inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder}}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -84,6 +84,9 @@ func (slot *projectLifecycleSlot) resolve(ctx context.Context, caps hostcaps, ow
|
||||
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
|
||||
@@ -91,9 +94,15 @@ func (h inprocHost) OwnedProjects() ([]core.Project, error) {
|
||||
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
|
||||
@@ -114,11 +123,29 @@ func (h inprocHost) ProjectMetadata(id string) (core.Project, error) {
|
||||
return p, nil
|
||||
}
|
||||
func (h inprocHost) EnsureProject(id, name string) (core.Project, error) {
|
||||
return h.caps.st.EnsureProject(h.owner, id, name)
|
||||
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) {
|
||||
return h.caps.st.RenameOwnedProject(h.owner, id, name)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,12 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func completeLifecycle() extension.ProjectLifecycle {
|
||||
@@ -55,6 +60,11 @@ func TestProjectHostMetadataDoesNotGrantMutation(t *testing.T) {
|
||||
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 {
|
||||
@@ -131,6 +141,9 @@ func TestProjectHostMetadataMasksSharedSecretsWithoutMutation(t *testing.T) {
|
||||
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)
|
||||
@@ -161,3 +174,180 @@ func TestProjectHostMetadataMasksSharedSecretsWithoutMutation(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ func TestScopedHostFactory(t *testing.T) {
|
||||
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}
|
||||
@@ -39,6 +42,9 @@ func TestScopedHostFactory(t *testing.T) {
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -17,19 +17,26 @@ 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
|
||||
projectSharedEmit ProjectSharedEmitter
|
||||
st *store.Store
|
||||
emit Emitter
|
||||
accountCheckedEmit AccountCheckedEmitter
|
||||
projectSharedEmit ProjectSharedEmitter
|
||||
// MirrorConvCards EXT-12:会话卡是否镜像进项目公共流(缺省 true=现状全量
|
||||
// 镜像;false 时会话卡只进本人会话流,项目级卡不受影响)。
|
||||
MirrorConvCards bool
|
||||
@@ -42,6 +49,10 @@ func New(st *store.Store, emit Emitter) *Service {
|
||||
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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+187
-35
@@ -49,6 +49,13 @@ type CommandTool struct {
|
||||
// 不投影给该 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 }
|
||||
|
||||
@@ -108,17 +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
|
||||
intents *IntentStore // G2-2:意图声明表(自然语言路由)
|
||||
toolGate ToolGate // U3:owner 级工具注入闸(nil = 不过滤)
|
||||
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 仍是热路径;重启后从存储取回。
|
||||
@@ -144,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}
|
||||
@@ -242,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)}
|
||||
}
|
||||
@@ -261,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}
|
||||
}
|
||||
@@ -279,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())
|
||||
@@ -289,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),
|
||||
@@ -341,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(req.Owner, 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()}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -21,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
|
||||
}
|
||||
@@ -38,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{}
|
||||
}
|
||||
@@ -54,7 +63,7 @@ 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 {
|
||||
@@ -70,9 +79,15 @@ func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error
|
||||
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
|
||||
}
|
||||
@@ -131,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: base.deliverables
|
||||
layer: base
|
||||
version: 7
|
||||
version: 9
|
||||
title: 交付物工作流
|
||||
description: 生成文档、表格、演示稿的工作流、样式决策与技能入口(常驻基座)
|
||||
---
|
||||
@@ -36,8 +36,10 @@ description: 生成文档、表格、演示稿的工作流、样式决策与技
|
||||
- 技能目录是环境变量 `AGENT_SKILLS_DIR`(环境段会写出绝对路径)。`PYTHONPATH` 已包含其中的 `house-style`,脚本里直接 `import housestyle`。
|
||||
- **house-style**:设计系统、三套主题、模板与仿制、lint。先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md`。
|
||||
- **docx-author / xlsx-author / pptx-author**:各类交付物的版面范式和最小示例。
|
||||
- **frontend-design / canvas-design / copywriting / copy-editing**(第三方 vendor,溯源见各自 UPSTREAM.md):视觉方向与排版审美、海报/信息图等 png·pdf 视觉产物(PIL 渲染,字体已随包)、营销/商务文案的撰写改写与编辑润色。交付物主题与配色仍以 house-style 为权威;需要超出主题规范的视觉判断或文案功力时 `load_skill` 对应技能。
|
||||
- 小队角色用 `load_skill`;其他路径用 `run_shell` 查看技能文件,例如 `cat "$AGENT_SKILLS_DIR/house-style/SKILL.md"`。不要假设工作区里有 `skills/` 目录。
|
||||
- 缺库时:`pip install --user python-docx openpyxl python-pptx`。PDF 仿制可选 `pdfplumber`;没装就说明只能近似,并请用户确认主色和字体。
|
||||
- 缺库又装不上时,如实告知用户当前环境无法生成带排版的 Office 交付物并说明缺什么;禁止改用 zipfile/字符串手工拼 OOXML 充数——那样生成的包缺母版与主题,结构自检会拒绝登记。
|
||||
|
||||
要求与边界:
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
id: tool.run_python
|
||||
layer: tool
|
||||
version: 1
|
||||
version: 2
|
||||
title: run_python 工具描述
|
||||
description: Python 执行原语的模型侧说明文案
|
||||
---
|
||||
执行完整 Python 脚本(python3,工作目录=会话工作区)。适用:数据处理、生成 Excel/PPT/图片(housestyle kit)、批量操作。Word 交付物优先写 .md 源后用 run_shell 跑 `python3 -m housestyle.build`,不要在脚本里手工拼 docx。报错先读退出码与 traceback 定位行号再改,同一错误不要原样重跑。生成带排式的文件(xlsx/pptx)先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md` 的构件表,不确定签名先 `inspect.signature`。
|
||||
执行完整 Python 脚本(python3,工作目录=会话工作区)。适用:数据处理、生成 Excel/PPT/图片(housestyle kit)、批量操作。Word 交付物优先写 .md 源后用 run_shell 跑 `python3 -m housestyle.build`。任何 Office 交付物(docx/xlsx/pptx)都禁止用 zipfile 或字符串手工拼 OOXML——手拼的包缺母版与主题,register_artifact 的结构自检会直接拒绝;一律走 housestyle kit 构件。缺库先 `pip install --user python-pptx python-docx openpyxl`,装不上就如实告知用户当前环境无法生成本类交付物,不要退回手拼。报错先读退出码与 traceback 定位行号再改,同一错误不要原样重跑。生成带排版的文件(xlsx/pptx)先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md` 的构件表,不确定签名先 `inspect.signature`。
|
||||
|
||||
@@ -228,6 +228,27 @@ func (s *Service) Save(owner string, m core.PromptModule) error {
|
||||
// Delete 撤销用户覆盖(回到内置)。
|
||||
func (s *Service) Delete(owner, id string) error { return s.st.DeletePromptModule(owner, id) }
|
||||
|
||||
// SaveAccountChecked applies the same user override rules as Save and uses
|
||||
// the Store's final actor-state transaction for the database write.
|
||||
func (s *Service) SaveAccountChecked(actor string, m core.PromptModule, allowImplicitDefault bool) error {
|
||||
if err := validateSubagentOverride(m); err != nil {
|
||||
return err
|
||||
}
|
||||
m.Source = string(core.SourceUser)
|
||||
m.Owner = actor
|
||||
for _, b := range s.builtin {
|
||||
if b.ID == m.ID {
|
||||
m.BasedOnVersion = b.Version
|
||||
break
|
||||
}
|
||||
}
|
||||
return s.st.SavePromptModuleAccountChecked(actor, m, allowImplicitDefault)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteAccountChecked(actor, id string, allowImplicitDefault bool) error {
|
||||
return s.st.DeletePromptModuleAccountChecked(actor, id, allowImplicitDefault)
|
||||
}
|
||||
|
||||
// MatchCommand 命令触发匹配:文本以某技能命令开头时返回该模块与剩余文本。
|
||||
func (s *Service) MatchCommand(owner, text string) (core.PromptModule, string, bool, error) {
|
||||
mods, err := s.Effective(owner)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func TestPromptServiceAccountCheckedWrites(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, err := NewService(st, []core.PromptModule{{ID: "task.example", Layer: core.LayerTask, Version: 2}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
module := core.PromptModule{ID: "task.example", Layer: core.LayerTask, Body: "Alice"}
|
||||
if err := svc.SaveAccountChecked("alice", module, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.GetPromptModule("alice", module.ID)
|
||||
if err != nil || got.Owner != "alice" || got.Source != string(core.SourceUser) || got.BasedOnVersion != 2 {
|
||||
t.Fatalf("service did not normalize actor module: %+v %v", got, err)
|
||||
}
|
||||
if err := svc.SaveAccountChecked("missing", module, false); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("missing actor saved prompt: %v", err)
|
||||
}
|
||||
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", module.ID, false); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled actor deleted prompt: %v", err)
|
||||
}
|
||||
if err := svc.SaveAccountChecked("alice", module, false); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled actor saved prompt: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package rag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
type checkedKnowledgeIndexer interface {
|
||||
IndexDocumentAccountChecked(ctx context.Context, owner, projectID core.ID, source, title, ref, plainText string, allowImplicitDefault bool) error
|
||||
IndexChunksAccountChecked(ctx context.Context, owner core.ID, docs []core.KnowledgeDoc, allowImplicitDefault bool) error
|
||||
PublishChunksAccountChecked(ctx context.Context, owner core.ID, pluginID string, scoped bool, docs []core.KnowledgeDoc, allowImplicitDefault bool) error
|
||||
}
|
||||
|
||||
func checkedIndexer(t *testing.T, p *Pipeline) checkedKnowledgeIndexer {
|
||||
t.Helper()
|
||||
i, ok := any(p).(checkedKnowledgeIndexer)
|
||||
if !ok {
|
||||
t.Fatal("Pipeline is missing account-checked indexing APIs")
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
type disableDuringEmbed struct{ st *store.Store }
|
||||
|
||||
func (e disableDuringEmbed) Embed(_ context.Context, texts []string) ([][]float32, error) {
|
||||
if _, err := e.st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vecs := make([][]float32, len(texts))
|
||||
for i := range vecs {
|
||||
vecs[i] = []float32{1, 0}
|
||||
}
|
||||
return vecs, nil
|
||||
}
|
||||
|
||||
func TestCheckedIndexStopsVectorWriteAfterEmbeddingDisablesAccount(t *testing.T) {
|
||||
p, st := newTestPipeline(t, nil, nil)
|
||||
i := checkedIndexer(t, p)
|
||||
if err := st.EnsureUser("alice", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.SetEmbedder(disableDuringEmbed{st: st})
|
||||
err := i.IndexChunksAccountChecked(context.Background(), "alice", []core.KnowledgeDoc{
|
||||
{ID: "knw-during-embed", Owner: "alice", Ref: "embedded", Source: "manual", Chunk: "A searchable document"},
|
||||
}, false)
|
||||
if !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("vector write after disable: got %v, want ErrAccountForbidden", err)
|
||||
}
|
||||
var lexical, vectors int
|
||||
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE owner='alice' AND ref='embedded'`).Scan(&lexical); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge_vectors WHERE owner='alice' AND ref='embedded'`).Scan(&vectors); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lexical != 1 || vectors != 0 {
|
||||
t.Fatalf("expected pre-disable lexical commit and no late vector: lexical=%d vectors=%d", lexical, vectors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckedIndexDocumentAndPublishUseGuardedKnowledge(t *testing.T) {
|
||||
p, st := newTestPipeline(t, nil, nil)
|
||||
i := checkedIndexer(t, p)
|
||||
if err := st.EnsureUser("alice", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := i.IndexDocumentAccountChecked(context.Background(), "alice", "p1", "upload", "minutes", "upload-ref", "A document with enough searchable text", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := i.PublishChunksAccountChecked(context.Background(), "alice", "alpha", true, []core.KnowledgeDoc{
|
||||
{ID: "knw-published", Owner: "alice", ProjectID: "p1", Ref: "published", Source: "plugin:alpha", Chunk: "A published document"},
|
||||
}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int
|
||||
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE owner='alice'`).Scan(&n); err != nil || n != 2 {
|
||||
t.Fatalf("guarded index and publish rows=%d, %v", n, err)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"agent-system-backend/internal/settings"
|
||||
@@ -85,6 +86,25 @@ func (p *Pipeline) IndexDocument(owner, projectID core.ID, source, title, ref, p
|
||||
return p.IndexChunks(context.Background(), owner, docs)
|
||||
}
|
||||
|
||||
// IndexDocumentAccountChecked is the upload/artifact indexing entry when the
|
||||
// caller has an authenticated actor. Chunking takes no DB connection; every
|
||||
// later lexical/vector write has its own final account transaction check.
|
||||
func (p *Pipeline) IndexDocumentAccountChecked(ctx context.Context, owner, projectID core.ID, source, title, ref, plainText string, allowImplicitDefault bool) error {
|
||||
if plainText == "" {
|
||||
return nil
|
||||
}
|
||||
doc := core.ParsedDocument{Blocks: []core.DocBlock{{Type: core.BlockPara, Text: plainText}}}
|
||||
chunks := p.chunker.Chunk(doc)
|
||||
if len(chunks) == 0 {
|
||||
return nil
|
||||
}
|
||||
docs := make([]core.KnowledgeDoc, len(chunks))
|
||||
for i, c := range chunks {
|
||||
docs[i] = core.KnowledgeDoc{Owner: owner, ProjectID: projectID, Source: source, Title: title, Ref: ref, Chunk: c.Text}
|
||||
}
|
||||
return p.IndexChunksAccountChecked(ctx, owner, docs, allowImplicitDefault)
|
||||
}
|
||||
|
||||
// IndexChunks 写入已构造好的知识分块并建向量(EXT-1 KnowledgeWrite embed=true 的路径)。
|
||||
// AddKnowledge 原地回填 docs[i].ID;embedding 失败降级为纯全文(记日志,不阻断写入)。
|
||||
func (p *Pipeline) IndexChunks(ctx context.Context, owner core.ID, docs []core.KnowledgeDoc) error {
|
||||
@@ -118,6 +138,74 @@ func (p *Pipeline) IndexChunks(ctx context.Context, owner core.ID, docs []core.K
|
||||
return nil
|
||||
}
|
||||
|
||||
// IndexChunksAccountChecked preserves batch atomicity for lexical rows and
|
||||
// fences each vector write after the external embedding call. Embedding cannot
|
||||
// share the DB transaction; a disable during that call leaves already
|
||||
// committed lexical rows but blocks subsequent vector publication.
|
||||
func (p *Pipeline) IndexChunksAccountChecked(ctx context.Context, owner core.ID, docs []core.KnowledgeDoc, allowImplicitDefault bool) error {
|
||||
if len(docs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := p.st.AddKnowledgeAccountChecked(string(owner), allowImplicitDefault, docs...); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.indexVectorsAccountChecked(ctx, owner, docs, allowImplicitDefault)
|
||||
}
|
||||
|
||||
// PublishChunksAccountChecked replaces all affected refs in one guarded Store
|
||||
// transaction, then builds vectors through individually guarded writes.
|
||||
func (p *Pipeline) PublishChunksAccountChecked(ctx context.Context, owner core.ID, pluginID string, scoped bool, docs []core.KnowledgeDoc, allowImplicitDefault bool) error {
|
||||
if len(docs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := p.st.ReplaceKnowledgeAccountChecked(ctx, string(owner), pluginID, scoped, allowImplicitDefault, docs...); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.indexVectorsAccountChecked(ctx, owner, docs, allowImplicitDefault)
|
||||
}
|
||||
|
||||
func (p *Pipeline) indexVectorsAccountChecked(ctx context.Context, owner core.ID, docs []core.KnowledgeDoc, allowImplicitDefault bool) error {
|
||||
if p.embedder == nil {
|
||||
return nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
account, err := p.st.GetAccountByOwner(string(owner))
|
||||
if errors.Is(err, store.ErrNotFound) && allowImplicitDefault && owner == store.OwnerDefault {
|
||||
err = nil
|
||||
account.Enabled = true
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !account.Enabled {
|
||||
return store.ErrAccountForbidden
|
||||
}
|
||||
texts := make([]string, len(docs))
|
||||
for i, d := range docs {
|
||||
texts[i] = d.Chunk
|
||||
}
|
||||
vecs, err := p.embedder.Embed(ctx, texts)
|
||||
if err != nil {
|
||||
log.Printf("[rag] embedding 降级为纯全文(ref=%s): %v", firstRef(docs), err)
|
||||
return nil
|
||||
}
|
||||
model := embedderModel(p.embedder)
|
||||
for i, v := range vecs {
|
||||
if i >= len(docs) {
|
||||
break
|
||||
}
|
||||
if len(v) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := p.st.UpsertKnowledgeVectorExtAccountChecked(ctx, string(owner), string(docs[i].ID), docs[i].Ref, model, contentHash(docs[i].Chunk), v, allowImplicitDefault); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// embedderModel 模型身份(可选接口:EmbeddingProvider 契约未含,实现侧
|
||||
// 声明;未声明=空=legacy 无身份行,向后兼容)。
|
||||
func embedderModel(e core.EmbeddingProvider) string {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// A missing domain provider must not make a registered account guard optional.
|
||||
func TestAccountGuardBlocksRunToolAndQueuedHITL(t *testing.T) {
|
||||
c, st := newExecEnv(t)
|
||||
if err := st.CreateAccount("alice", "Alice", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.mgr.SetAccountEffectiveAuthorization(func(_ context.Context, owner string) (bool, error) {
|
||||
a, err := st.GetAccountByOwner(owner)
|
||||
return err == nil && a.Enabled, err
|
||||
})
|
||||
c.run.ID = store.NewID("run")
|
||||
c.run.Status = core.RunWaitingHITL
|
||||
if err := st.CreateRun(&c.run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &runHandle{hitlCh: make(chan hitlAnswer, 1)}
|
||||
c.mgr.active = map[string]*runHandle{c.run.ID: h}
|
||||
card := core.HITLCard{ID: "disabled-card", RunID: c.run.ID, ConversationID: c.run.ConversationID}
|
||||
if err := st.SaveHITLCard("alice", &card); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conv, err := st.GetConversation("alice", c.run.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.mgr.StartConversationRun(conv, core.Message{}, nil, nil); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("disabled account started run without domain provider: %v", err)
|
||||
}
|
||||
res := c.ExecuteTool(core.ToolCallRequest{CallID: "disabled-tool", Name: "write_artifact", Arguments: json.RawMessage(`{"name":"denied.txt","content":"x"}`)})
|
||||
if res.Status != "error" {
|
||||
t.Fatalf("disabled account executed tool: %+v", res)
|
||||
}
|
||||
arts, err := st.ListArtifacts("alice", c.run.ProjectID)
|
||||
if err != nil || len(arts) != 0 {
|
||||
t.Fatalf("disabled account wrote artifact: %+v %v", arts, err)
|
||||
}
|
||||
|
||||
if _, _, err := c.mgr.ResolveHITL("alice", card.ID, core.HITLResponse{ChoiceID: "yes"}); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("disabled account resolved HITL: %v", err)
|
||||
}
|
||||
persisted, err := st.GetHITLCard("alice", card.ID)
|
||||
if err != nil || persisted.Resolved || len(h.hitlCh) != 0 {
|
||||
t.Fatalf("disabled HITL changed state: %+v channel=%d err=%v", persisted, len(h.hitlCh), err)
|
||||
}
|
||||
payload, _ := json.Marshal(HITLCommandPayload{Owner: "alice", CardID: card.ID, Kind: "yes"})
|
||||
c.mgr.applyHITLCommand(ownedCommand{CommandID: "queued-disabled", Payload: string(payload)})
|
||||
if len(h.hitlCh) != 0 {
|
||||
t.Fatal("queued approval delivered to disabled run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountGuardStoreReadFailureBlocksOutput(t *testing.T) {
|
||||
c, st := newExecEnv(t)
|
||||
runCtx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
c.ctx = runCtx
|
||||
c.mgr.active = map[string]*runHandle{"active": {owner: "alice", cancel: cancel}}
|
||||
readErr := errors.New("account store unavailable")
|
||||
c.mgr.SetAccountEffectiveAuthorization(func(context.Context, string) (bool, error) { return false, readErr })
|
||||
if err := c.Emit(core.EvToolOutput, core.ToolOutputPayload{Text: "secret"}); !errors.Is(err, readErr) {
|
||||
t.Fatalf("store failure emitted output: %v", err)
|
||||
}
|
||||
if runCtx.Err() == nil {
|
||||
t.Fatal("account-state read failure left active local run executing")
|
||||
}
|
||||
if err := c.SetAssistantParts([]core.Part{{Type: core.PartText, Text: "secret"}}); !errors.Is(err, readErr) {
|
||||
t.Fatalf("store failure retained assistant output: %v", err)
|
||||
}
|
||||
evs, err := st.EventsAfter(c.run.ConversationID, 0, 100)
|
||||
if err != nil || len(evs) != 0 {
|
||||
t.Fatalf("store failure persisted output events: %+v %v", evs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountGuardBlocksRestoredNoticeOutput(t *testing.T) {
|
||||
c, st := newExecEnv(t)
|
||||
if err := st.CreateAccount("alice", "Alice", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.mgr.SetAccountEffectiveAuthorization(func(_ context.Context, owner string) (bool, error) {
|
||||
a, err := st.GetAccountByOwner(owner)
|
||||
return err == nil && a.Enabled, err
|
||||
})
|
||||
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.mgr.startNoticeRun(c.run.ConversationID, []bgNotice{{owner: "alice", name: "restored", payload: core.AgentResultPayload{Summary: "must not deliver"}}}); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("restored notice delivered to disabled account: %v", err)
|
||||
}
|
||||
msgs, err := st.ListMessages(c.run.ConversationID)
|
||||
if err != nil || len(msgs) != 0 {
|
||||
t.Fatalf("restored notice persisted output: %+v %v", msgs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountGuardDiscardsComposedOutputAfterDisable(t *testing.T) {
|
||||
c, st := newExecEnv(t)
|
||||
if err := st.CreateAccount("alice", "Alice", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.mgr.SetAccountEffectiveAuthorization(func(_ context.Context, owner string) (bool, error) {
|
||||
a, err := st.GetAccountByOwner(owner)
|
||||
return err == nil && a.Enabled, err
|
||||
})
|
||||
c.run.ID = store.NewID("run")
|
||||
c.run.Status = core.RunRunning
|
||||
if err := st.CreateRun(&c.run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.h = &runHandle{assistant: []core.Part{{Type: core.PartText, Text: "must not persist"}}}
|
||||
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.mgr.finishRun(c, core.RunCompleted, core.Usage{}, nil)
|
||||
got, err := st.GetRunByID(c.run.ID)
|
||||
if err != nil || got.Status == core.RunCompleted {
|
||||
t.Fatalf("disabled run completed: %+v %v", got, err)
|
||||
}
|
||||
msgs, err := st.ListMessages(c.run.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, msg := range msgs {
|
||||
for _, part := range msg.Parts {
|
||||
if part.Text == "must not persist" {
|
||||
t.Fatal("previously composed assistant output persisted after disable")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,16 +70,23 @@ func (g *admissionGate) QueueFull() bool {
|
||||
}
|
||||
|
||||
// acquire 阻塞获取执行许可(ctx 取消则放弃排队)。预算关闭时零开销直通。
|
||||
func (g *admissionGate) acquire(ctx context.Context, st *store.Store, runID, owner string) error {
|
||||
func (g *admissionGate) acquire(ctx context.Context, st *store.Store, runID, owner string, accountGuard ...bool) error {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
budget := g.budget()
|
||||
if budget.Off() {
|
||||
return nil
|
||||
return ctx.Err()
|
||||
}
|
||||
for {
|
||||
if err := st.TryAcquireExecutionSlot(runID, owner, budget); err == nil {
|
||||
var err error
|
||||
if len(accountGuard) > 0 && accountGuard[0] {
|
||||
allowImplicit := len(accountGuard) > 1 && accountGuard[1]
|
||||
err = st.TryAcquireExecutionSlotAccountChecked(owner, runID, budget, allowImplicit)
|
||||
} else {
|
||||
err = st.TryAcquireExecutionSlot(runID, owner, budget)
|
||||
}
|
||||
if err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, store.ErrBudgetExceeded) {
|
||||
return err // 存储故障:不放行(fail-closed,宁可拒绝执行)
|
||||
|
||||
@@ -170,7 +170,7 @@ func (m *Manager) applyHITLCommand(c ownedCommand) {
|
||||
_, _, err := m.ResolveHITL(p.Owner, p.CardID, resp)
|
||||
if err != nil {
|
||||
// 过期/未知 gate:明确销项(resolved + 注记),发出可见结论。
|
||||
_ = m.st.ResolveHITLCard(p.Owner, p.CardID, core.HITLResolvedPayload{
|
||||
_ = m.resolveHITLCardForEffectiveAccount(p.Owner, p.CardID, core.HITLResolvedPayload{
|
||||
CardID: core.ID(p.CardID), Kind: "expired", Answer: "跨实例审批迟到:gate 已过期或执行已移交",
|
||||
})
|
||||
log.Printf("[runmgr] hitl %s (card %s) rejected: %v", c.CommandID, p.CardID, err)
|
||||
|
||||
@@ -20,10 +20,12 @@ import (
|
||||
// 会话内事件天然单线程(一个会话至多一个在途 run,子代理在父执行栈内同步运行),
|
||||
// 少数跨 goroutine 路径(HITL resolve)与冲盘互斥由 flushing 锁保证不交叉。
|
||||
type eventBuffer struct {
|
||||
store *store.Store
|
||||
bus *bus.Bus
|
||||
interval time.Duration // 定时冲盘窗口
|
||||
maxBuf int // 单会话缓冲条数上限(触发立即冲盘)
|
||||
store *store.Store
|
||||
bus *bus.Bus
|
||||
accountGuardEnabled bool
|
||||
allowImplicitLocal bool
|
||||
interval time.Duration // 定时冲盘窗口
|
||||
maxBuf int // 单会话缓冲条数上限(触发立即冲盘)
|
||||
// onPersisted 落盘+本地发布后的回调(PG 档 NOTIFY 跨实例扇出;nil = 无)。
|
||||
onPersisted func(conversationID string, evs []core.StreamEvent)
|
||||
|
||||
@@ -116,6 +118,41 @@ func (eb *eventBuffer) emit(conversationID, runID string, typ core.StreamEventTy
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// emitAccountChecked flushes any earlier deltas and the new structural event
|
||||
// in one final account-checked Store transaction. A disabled owner loses its
|
||||
// buffered deltas instead of persisting them after disable.
|
||||
func (eb *eventBuffer) emitAccountChecked(actor, conversationID, runID string, typ core.StreamEventType, payload any, allowImplicitDefault bool) error {
|
||||
e := core.StreamEvent{ConversationID: conversationID, RunID: runID,
|
||||
TS: time.Now().UTC().Format(time.RFC3339Nano), V: core.ProtocolVersion, Type: typ}
|
||||
if projectID, ok := core.ProjectFromChannel(core.ID(conversationID)); ok {
|
||||
e.ProjectID = projectID
|
||||
}
|
||||
if payload != nil {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.Payload = raw
|
||||
}
|
||||
eb.flushing.Lock()
|
||||
defer eb.flushing.Unlock()
|
||||
eb.mu.Lock()
|
||||
batch := append([]core.StreamEvent(nil), eb.buf[conversationID]...)
|
||||
delete(eb.buf, conversationID)
|
||||
eb.mu.Unlock()
|
||||
batch = append(batch, e)
|
||||
if _, err := eb.store.AppendEventsAccountChecked(actor, conversationID, batch, allowImplicitDefault); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, event := range batch {
|
||||
eb.bus.Publish(event)
|
||||
}
|
||||
if eb.onPersisted != nil {
|
||||
eb.onPersisted(conversationID, batch)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// emitProjectShared persists an explicit internal publication and its proof
|
||||
// before fan-out. A client-visible payload cannot opt itself into this path.
|
||||
func (eb *eventBuffer) emitProjectShared(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error {
|
||||
@@ -132,7 +169,12 @@ func (eb *eventBuffer) emitProjectShared(owner, producer, channel, runID string,
|
||||
eb.flushing.Lock()
|
||||
defer eb.flushing.Unlock()
|
||||
events := []core.StreamEvent{event}
|
||||
if _, err := eb.store.AppendProjectSharedEvents(owner, producer, channel, events); err != nil {
|
||||
if eb.accountGuardEnabled {
|
||||
_, err = eb.store.AppendProjectSharedEventsAccountChecked(owner, producer, channel, events, eb.allowImplicitLocal)
|
||||
} else {
|
||||
_, err = eb.store.AppendProjectSharedEvents(owner, producer, channel, events)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, event := range events {
|
||||
@@ -148,7 +190,13 @@ func (eb *eventBuffer) emitProjectShared(owner, producer, channel, runID string,
|
||||
func (eb *eventBuffer) append(conversationID string, evs []core.StreamEvent) error {
|
||||
eb.flushing.Lock()
|
||||
defer eb.flushing.Unlock()
|
||||
if _, err := eb.store.AppendEvents(conversationID, evs); err != nil {
|
||||
var err error
|
||||
if eb.accountGuardEnabled {
|
||||
_, err = eb.store.AppendEventsForEffectiveChannel(conversationID, evs, eb.allowImplicitLocal)
|
||||
} else {
|
||||
_, err = eb.store.AppendEvents(conversationID, evs)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range evs {
|
||||
|
||||
@@ -77,9 +77,11 @@ func TestRunPythonRegisterArtifactBackfill(t *testing.T) {
|
||||
if err := tools.RegisterInterpreterTools(c.mgr.registry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 夹具必须是结构合法的最小 docx:登记口会做 OPC 结构自检,
|
||||
// 伪 zip(PK 魔数+垃圾字节)现在会被拒绝而非登记。
|
||||
py := c.ExecuteTool(core.ToolCallRequest{
|
||||
CallID: "py1", Name: "run_python",
|
||||
Arguments: json.RawMessage(`{"script":"open('note.docx','wb').write(b'PK\\x03\\x04hello')\n"}`),
|
||||
Arguments: json.RawMessage(`{"script":"import zipfile\nwith zipfile.ZipFile('note.docx','w') as z:\n z.writestr('[Content_Types].xml','<?xml version=\"1.0\"?><Types/>')\n z.writestr('word/document.xml','<?xml version=\"1.0\"?><doc/>')\n"}`),
|
||||
})
|
||||
if py.Status != "ok" || !strings.Contains(py.Output, "exit=0") {
|
||||
t.Fatalf("run_python: %s %s", py.Status, py.Output)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
type checkedFileEffects interface {
|
||||
stageInputsChecked(ctx context.Context, conv core.Conversation, eff core.EffectiveSettings, files []WorkspaceFile, check func(context.Context) error) ([]string, error)
|
||||
persistInputTextsChecked(ctx context.Context, owner, conversation string, attachments []core.ParsedDocument, check func(context.Context) error) error
|
||||
writeSubagentRequestChecked(ctx context.Context, run core.Run, agentID, name, task, hint string, detach bool, check func(context.Context) error) error
|
||||
writeSubagentTranscriptChecked(ctx context.Context, run core.Run, parts []core.Part, status string, check func(context.Context) error) error
|
||||
}
|
||||
|
||||
func checkedFiles(t *testing.T, m *Manager) checkedFileEffects {
|
||||
t.Helper()
|
||||
c, ok := any(m).(checkedFileEffects)
|
||||
if !ok {
|
||||
t.Fatal("Manager is missing checked workspace/transcript file effects")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func rejectAt(n int, sentinel error) (func(context.Context) error, *int) {
|
||||
calls := 0
|
||||
return func(ctx context.Context) error {
|
||||
calls++
|
||||
if calls == n {
|
||||
return sentinel
|
||||
}
|
||||
return nil
|
||||
}, &calls
|
||||
}
|
||||
|
||||
func TestCheckedStageInputsStopsBeforeEachFileEffect(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
m := &Manager{workspaceRoot: root}
|
||||
c := checkedFiles(t, m)
|
||||
conv := core.Conversation{ID: "conv", Owner: "alice"}
|
||||
denied := errors.New("account disabled")
|
||||
check, calls := rejectAt(3, denied)
|
||||
paths, err := c.stageInputsChecked(context.Background(), conv, core.EffectiveSettings{}, []WorkspaceFile{
|
||||
{Name: "first.txt", Content: []byte("first")},
|
||||
{Name: "second.txt", Content: []byte("second")},
|
||||
}, check)
|
||||
if !errors.Is(err, denied) || *calls != 3 || len(paths) != 1 || paths[0] != "inputs/first.txt" {
|
||||
t.Fatalf("late file guard: paths=%v calls=%d err=%v", paths, *calls, err)
|
||||
}
|
||||
dir := filepath.Join(root, "alice", "conv", "inputs")
|
||||
if body, err := os.ReadFile(filepath.Join(dir, "first.txt")); err != nil || string(body) != "first" {
|
||||
t.Fatalf("first file should predate rejection: %q, %v", body, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "second.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("late second file was written: %v", err)
|
||||
}
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := c.stageInputsChecked(cancelled, core.Conversation{ID: "other", Owner: "alice"}, core.EffectiveSettings{}, nil, nil); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancelled stage: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "alice", "other")); !os.IsNotExist(err) {
|
||||
t.Fatalf("cancelled stage created directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckedPersistTextRejectsBeforeWrite(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
m := &Manager{workspaceRoot: root}
|
||||
c := checkedFiles(t, m)
|
||||
dir := filepath.Join(root, "alice", "conv", "inputs")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
docs := []core.ParsedDocument{{
|
||||
Meta: core.DocMeta{Filename: "minutes.docx"},
|
||||
Blocks: []core.DocBlock{{Type: core.BlockPara, Text: strings.Repeat("substantial meeting content ", 20)}},
|
||||
}}
|
||||
denied := errors.New("account disabled")
|
||||
check, calls := rejectAt(1, denied)
|
||||
if err := c.persistInputTextsChecked(context.Background(), "alice", "conv", docs, check); !errors.Is(err, denied) || *calls != 1 {
|
||||
t.Fatalf("persist guard: calls=%d err=%v", *calls, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "minutes.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("persist wrote after rejection: %v", err)
|
||||
}
|
||||
if err := c.persistInputTextsChecked(context.Background(), "alice", "conv", docs, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "minutes.md")); err != nil {
|
||||
t.Fatalf("permitted persist missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckedSubagentTranscriptStopsBeforeAppendAndDoneMarker(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
m := &Manager{workspaceRoot: root}
|
||||
c := checkedFiles(t, m)
|
||||
run := core.Run{ID: "child", Owner: "alice", ConversationID: "conv"}
|
||||
denied := errors.New("account disabled")
|
||||
check, calls := rejectAt(2, denied)
|
||||
if err := c.writeSubagentRequestChecked(context.Background(), run, "agent", "name", "task", "", true, check); !errors.Is(err, denied) || *calls != 2 {
|
||||
t.Fatalf("request guard: calls=%d err=%v", *calls, err)
|
||||
}
|
||||
dir := filepath.Join(root, "alice", "conv", "subagents", "child")
|
||||
if _, err := os.Stat(filepath.Join(dir, "request.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("request written after rejection: %v", err)
|
||||
}
|
||||
if err := c.writeSubagentRequestChecked(context.Background(), run, "agent", "name", "task", "", true, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check, calls = rejectAt(4, denied) // mkdir, open, first append, second append
|
||||
parts := []core.Part{{Type: core.PartText, Text: "first"}, {Type: core.PartText, Text: "second"}}
|
||||
if err := c.writeSubagentTranscriptChecked(context.Background(), run, parts, "completed", check); !errors.Is(err, denied) || *calls != 4 {
|
||||
t.Fatalf("append guard: calls=%d err=%v", *calls, err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(dir, "transcript.jsonl"))
|
||||
if err != nil || !strings.Contains(string(body), "first") || strings.Contains(string(body), "second") {
|
||||
t.Fatalf("transcript after rejection: %q, %v", body, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "done.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("done marker written after rejection: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckedResumePassesOwnerAndStopsBeforeRedispatch(t *testing.T) {
|
||||
c, st := newSpawnEnv(t, &gateRunner{})
|
||||
c.mgr.runner = noopRunner{}
|
||||
c.mgr.SetWorkspaceRoot(t.TempDir())
|
||||
parent := c.run
|
||||
if err := st.CreateRun(&parent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child := core.Run{
|
||||
ID: store.NewID("run"), ParentRunID: parent.ID, ConversationID: parent.ConversationID,
|
||||
ProjectID: parent.ProjectID, Owner: parent.Owner, Kind: core.RunSubagent,
|
||||
ProfileID: parent.ProfileID, Status: core.RunRunning, CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := st.CreateRun(&child); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.mgr.writeSubagentRequest(child, "agent.sub.explore", "name", "task", "", true)
|
||||
if _, err := st.SealInterrupted(child.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := st.SubagentRunIDs(parent.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
denied := errors.New("account disabled")
|
||||
var checkedOwner string
|
||||
c.mgr.ResumeInterruptedChildrenChecked(context.Background(), func(_ context.Context, owner string) error {
|
||||
checkedOwner = owner
|
||||
return denied
|
||||
})
|
||||
if checkedOwner != child.Owner {
|
||||
t.Fatalf("resume checked owner %q, want %q", checkedOwner, child.Owner)
|
||||
}
|
||||
after, err := st.SubagentRunIDs(parent.ConversationID)
|
||||
if err != nil || len(after) != len(before) {
|
||||
t.Fatalf("denied resume spawned child: before=%d after=%d err=%v", len(before), len(after), err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(c.mgr.subagentDir(child.Owner, child.ConversationID, child.ID), "resumed.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("denied resume wrote marker: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func (m *Manager) maybeGenUIAuto(owner, convID, reason string) {
|
||||
ConversationID: convID, Role: core.RoleSystem,
|
||||
Parts: []core.Part{{Type: core.PartText, Text: text}},
|
||||
}
|
||||
_ = m.st.AppendMessage(&msg)
|
||||
_ = m.appendMessageForEffectiveAccount(owner, &msg)
|
||||
}
|
||||
|
||||
func planStageChanged(prev, next core.PlanSnapshot) bool {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -19,10 +20,20 @@ type WorkspaceFile struct {
|
||||
// StageWorkspaceInputs 把原件和可选默认模板写到 <root>/<owner>/<conversation>/inputs/。
|
||||
// root 为空时不写(工作区未配置)。返回工作区相对路径。同一批里重名的文件加序号。
|
||||
func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFile, templateName string, template []byte) ([]string, error) {
|
||||
return StageWorkspaceInputsChecked(context.Background(), root, owner, conversation, files, templateName, template, nil)
|
||||
}
|
||||
|
||||
// StageWorkspaceInputsChecked rechecks the actor immediately before each
|
||||
// directory/file mutation. The caller supplies its effective-account check;
|
||||
// check and filesystem syscall cannot be atomic with an account DB commit.
|
||||
func StageWorkspaceInputsChecked(ctx context.Context, root, owner, conversation string, files []WorkspaceFile, templateName string, template []byte, check func(context.Context) error) ([]string, error) {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
dir := filepath.Join(root, owner, conversation, "inputs")
|
||||
if err := checkFileEffect(ctx, check); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -34,6 +45,9 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil
|
||||
continue
|
||||
}
|
||||
name = uniqueInputName(name, used)
|
||||
if err := checkFileEffect(ctx, check); err != nil {
|
||||
return rels, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), file.Content, 0o644); err != nil {
|
||||
return rels, err
|
||||
}
|
||||
@@ -47,6 +61,9 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil
|
||||
ext = filepath.Ext(base)
|
||||
}
|
||||
name := "_default-template" + ext
|
||||
if err := checkFileEffect(ctx, check); err != nil {
|
||||
return rels, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), template, 0o644); err != nil {
|
||||
return rels, err
|
||||
}
|
||||
@@ -55,13 +72,29 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil
|
||||
return rels, nil
|
||||
}
|
||||
|
||||
func checkFileEffect(ctx context.Context, check func(context.Context) error) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if check != nil {
|
||||
return check(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PersistInputTexts 把附件解析出的纯文本写成 inputs/<原名>.md,与原件并列。
|
||||
// 后续轮次的上下文里原件只剩占位时,模型仍能读到可检索的原文文本——
|
||||
// 「根据纪要制定方案」不再只能依赖上一轮产物(实测后续轮 0 次读原件)。
|
||||
// 原本就是文本类(md/txt/csv)的跳过;解析文本过短(<200 字)视为解析失败跳过。
|
||||
func PersistInputTexts(root, owner, conversation string, attachments []core.ParsedDocument) {
|
||||
_ = PersistInputTextsChecked(context.Background(), root, owner, conversation, attachments, nil)
|
||||
}
|
||||
|
||||
// PersistInputTextsChecked propagates a failed final check or file write so a
|
||||
// guarded caller stops before later extracted files are published.
|
||||
func PersistInputTextsChecked(ctx context.Context, root, owner, conversation string, attachments []core.ParsedDocument, check func(context.Context) error) error {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
dir := filepath.Join(root, owner, conversation, "inputs")
|
||||
for _, doc := range attachments {
|
||||
@@ -82,10 +115,14 @@ func PersistInputTexts(root, owner, conversation string, attachments []core.Pars
|
||||
if _, err := os.Stat(out); err == nil {
|
||||
continue // 已有同名解析文本(同一附件重发)不覆盖
|
||||
}
|
||||
if err := os.WriteFile(out, []byte(text), 0o644); err == nil {
|
||||
_ = out
|
||||
if err := checkFileEffect(ctx, check); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(out, []byte(text), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListWorkspaceInputs 列出会话工作区 inputs/ 目录的相对路径(原样返回给环境段)。
|
||||
@@ -110,6 +147,10 @@ func ListWorkspaceInputs(root, owner, conversation string, staged []string) []st
|
||||
}
|
||||
|
||||
func (m *Manager) stageInputs(conv core.Conversation, eff core.EffectiveSettings, files []WorkspaceFile) ([]string, error) {
|
||||
return m.stageInputsChecked(context.Background(), conv, eff, files, nil)
|
||||
}
|
||||
|
||||
func (m *Manager) stageInputsChecked(ctx context.Context, conv core.Conversation, eff core.EffectiveSettings, files []WorkspaceFile, check func(context.Context) error) ([]string, error) {
|
||||
var templateName string
|
||||
var template []byte
|
||||
if id := strings.TrimSpace(eff.GetString("deliverables.templateArtifactId", "")); id != "" && m.st != nil {
|
||||
@@ -119,7 +160,11 @@ func (m *Manager) stageInputs(conv core.Conversation, eff core.EffectiveSettings
|
||||
}
|
||||
}
|
||||
}
|
||||
return StageWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, files, templateName, template)
|
||||
return StageWorkspaceInputsChecked(ctx, m.workspaceRoot, conv.Owner, conv.ID, files, templateName, template, check)
|
||||
}
|
||||
|
||||
func (m *Manager) persistInputTextsChecked(ctx context.Context, owner, conversation string, attachments []core.ParsedDocument, check func(context.Context) error) error {
|
||||
return PersistInputTextsChecked(ctx, m.workspaceRoot, owner, conversation, attachments, check)
|
||||
}
|
||||
|
||||
func safeInputName(name string) (string, bool) {
|
||||
|
||||
@@ -12,9 +12,11 @@ import (
|
||||
// storeMemory 把 agent-native 的记忆端口接到 memory_entries 表(N3 WP5)。
|
||||
// owner/项目态由适配器持有(端口本身不传租户态)。
|
||||
type storeMemory struct {
|
||||
st *store.Store
|
||||
owner string
|
||||
projectID string
|
||||
st *store.Store
|
||||
owner string
|
||||
projectID string
|
||||
checked bool
|
||||
allowImplicitDefault bool
|
||||
}
|
||||
|
||||
// NewStoreMemory 构建端口实现(owner 为空时用默认租户)。
|
||||
@@ -25,6 +27,10 @@ func NewStoreMemory(st *store.Store, owner, projectID string) memory.Store {
|
||||
return &storeMemory{st: st, owner: owner, projectID: projectID}
|
||||
}
|
||||
|
||||
func newAccountCheckedStoreMemory(st *store.Store, owner, projectID string, allowImplicitDefault bool) memory.Store {
|
||||
return &storeMemory{st: st, owner: owner, projectID: projectID, checked: true, allowImplicitDefault: allowImplicitDefault}
|
||||
}
|
||||
|
||||
// Recall 按 scope 过滤(空 = 不限,保持迁移前 Memories() 的全量语义),
|
||||
// 过期与空内容条目的剔除与文案渲染在 memory.Block(同一形态)。
|
||||
func (m *storeMemory) Recall(ctx context.Context, q memory.RecallQuery) ([]memory.Entry, error) {
|
||||
@@ -59,6 +65,9 @@ func (c *runCtx) StoreMemory() memory.Store {
|
||||
if c.mgr == nil || c.mgr.st == nil {
|
||||
return nil
|
||||
}
|
||||
if c.mgr.accountEffectiveAuthorization != nil {
|
||||
return newAccountCheckedStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID, c.mgr.accountAllowImplicitLocal)
|
||||
}
|
||||
return NewStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID)
|
||||
}
|
||||
|
||||
@@ -73,10 +82,16 @@ func (m *storeMemory) Append(ctx context.Context, e memory.Entry) error {
|
||||
if created.IsZero() {
|
||||
created = now
|
||||
}
|
||||
_, err := m.st.PutMemory(m.owner, core.MemoryEntry{
|
||||
entry := core.MemoryEntry{
|
||||
ID: e.ID, Scope: e.Scope, ProjectID: core.ID(projectID), Type: e.Type,
|
||||
Content: e.Content, Source: e.Source, Confidence: e.Confidence,
|
||||
CreatedAt: created, ExpiresAt: e.ExpiresAt,
|
||||
})
|
||||
}
|
||||
var err error
|
||||
if m.checked {
|
||||
_, err = m.st.PutMemoryAccountChecked(m.owner, entry, m.allowImplicitDefault)
|
||||
} else {
|
||||
_, err = m.st.PutMemory(m.owner, entry)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
@@ -80,6 +81,11 @@ func (m *Manager) RewindFiles(owner, convID, messageID string) (checkpoint.Resul
|
||||
if err != nil {
|
||||
return checkpoint.Result{}, err
|
||||
}
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
if err := m.authorizeAccountEffective(context.Background(), owner); err != nil {
|
||||
return checkpoint.Result{}, err
|
||||
}
|
||||
}
|
||||
res, err := cpLog.Rewind(messageID, ordered)
|
||||
if err != nil {
|
||||
return checkpoint.Result{}, err
|
||||
@@ -93,7 +99,12 @@ func (m *Manager) RewindFiles(owner, convID, messageID string) (checkpoint.Resul
|
||||
ConversationID: convID, Role: core.RoleSystem,
|
||||
Parts: []core.Part{{Type: core.PartText, Text: note}},
|
||||
}
|
||||
if err := m.st.AppendMessage(&msg); err != nil {
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
err = m.st.AppendMessageWithRequestAccountChecked(owner, m.accountAllowImplicitLocal, &msg, "")
|
||||
} else {
|
||||
err = m.st.AppendMessage(&msg)
|
||||
}
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
return res, nil
|
||||
|
||||
+438
-83
@@ -57,10 +57,12 @@ type Manager struct {
|
||||
retriever core.Retriever // 可选混合检索(nil = store 全文)
|
||||
// sharedKnowledgeAllow EXT-13:回合内检索的共享授权谓词(请求 owner × 知识所属
|
||||
// projectID);nil = 未注册(现状:仅本人两路,不触碰共享通道)。
|
||||
sharedKnowledgeAllow func(owner string, projectID core.ID) bool
|
||||
knowledgeResourceAllow func(owner string, doc core.KnowledgeDoc) bool
|
||||
knowledgeProjectScope func(context.Context, string, core.ID) (core.ID, error)
|
||||
projectWorkAuthorization func(context.Context, string, core.ID) (bool, error)
|
||||
sharedKnowledgeAllow func(owner string, projectID core.ID) bool
|
||||
knowledgeResourceAllow func(owner string, doc core.KnowledgeDoc) bool
|
||||
knowledgeProjectScope func(context.Context, string, core.ID) (core.ID, error)
|
||||
projectWorkAuthorization func(context.Context, string, core.ID) (bool, error)
|
||||
accountEffectiveAuthorization func(context.Context, string) (bool, error)
|
||||
accountAllowImplicitLocal bool
|
||||
// commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。
|
||||
commands CommandSource
|
||||
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
|
||||
@@ -132,6 +134,7 @@ type bgNotice struct {
|
||||
var ErrConversationBusy = fmt.Errorf("runmgr: conversation already has an in-flight run")
|
||||
|
||||
type runHandle struct {
|
||||
owner string
|
||||
cancel context.CancelFunc
|
||||
hitlCh chan hitlAnswer
|
||||
assistant []core.Part
|
||||
@@ -197,6 +200,73 @@ func (m *Manager) EmitCardEvent(conversationID, runID string, typ core.StreamEve
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) EmitCardEventAccountChecked(actor, conversationID, runID string, typ core.StreamEventType, payload any) error {
|
||||
return m.events.emitAccountChecked(actor, conversationID, runID, typ, payload, m.accountAllowImplicitLocal)
|
||||
}
|
||||
|
||||
func (m *Manager) appendMessageForEffectiveAccount(actor string, msg *core.Message) error {
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
return m.st.AppendMessageWithRequestAccountChecked(actor, m.accountAllowImplicitLocal, msg, "")
|
||||
}
|
||||
return m.st.AppendMessage(msg)
|
||||
}
|
||||
|
||||
func (m *Manager) saveHITLCardForEffectiveAccount(actor string, card *core.HITLCard) error {
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
return m.st.SaveHITLCardAccountChecked(actor, card, m.accountAllowImplicitLocal)
|
||||
}
|
||||
return m.st.SaveHITLCard(actor, card)
|
||||
}
|
||||
|
||||
func (m *Manager) resolveHITLCardForEffectiveAccount(actor, id string, resolution core.HITLResolvedPayload) error {
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
return m.st.ResolveHITLCardAccountChecked(actor, id, resolution, m.accountAllowImplicitLocal)
|
||||
}
|
||||
return m.st.ResolveHITLCard(actor, id, resolution)
|
||||
}
|
||||
|
||||
func (m *Manager) resolveProjectedCardForEffectiveAccount(actor, ref, actionID, answer string) error {
|
||||
if m.cards == nil {
|
||||
return nil
|
||||
}
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
return m.cards.ResolveByRefAccountChecked(actor, ref, actionID, answer, m.accountAllowImplicitLocal)
|
||||
}
|
||||
return m.cards.ResolveByRef(actor, ref, actionID, answer)
|
||||
}
|
||||
|
||||
func (m *Manager) createCardForEffectiveAccount(actor string, card core.ActivityCard, ref string) (core.ActivityCard, error) {
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
return m.cards.CreateAccountChecked(actor, card, ref, m.accountAllowImplicitLocal)
|
||||
}
|
||||
return m.cards.Create(actor, card, ref)
|
||||
}
|
||||
|
||||
func (m *Manager) setCardStatusForEffectiveAccount(actor, id string, status core.CardStatus, tone, note string) (core.ActivityCard, error) {
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
return m.cards.SetStatusAccountChecked(actor, id, status, tone, note, m.accountAllowImplicitLocal)
|
||||
}
|
||||
return m.cards.SetStatus(actor, id, status, tone, note)
|
||||
}
|
||||
|
||||
func (m *Manager) updateCardForEffectiveAccount(actor string, card core.ActivityCard) error {
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
_, version, err := m.st.GetCardVersion(actor, card.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.cards.UpdateVersionedAccountChecked(actor, card, version, m.accountAllowImplicitLocal)
|
||||
}
|
||||
return m.cards.Update(actor, card)
|
||||
}
|
||||
|
||||
func (m *Manager) appendCardForEffectiveAccount(actor, id, text string) error {
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
return m.cards.AppendAccountChecked(actor, id, text, m.accountAllowImplicitLocal)
|
||||
}
|
||||
return m.cards.Append(actor, id, text)
|
||||
}
|
||||
|
||||
// SetRetriever 注入混合检索(rag 管线;nil = store 全文检索)。
|
||||
func (m *Manager) SetRetriever(r core.Retriever) { m.retriever = r }
|
||||
|
||||
@@ -217,6 +287,47 @@ func (m *Manager) SetKnowledgeResourceAllow(fn func(string, core.KnowledgeDoc) b
|
||||
func (m *Manager) SetProjectWorkAuthorization(fn func(context.Context, string, core.ID) (bool, error)) {
|
||||
m.projectWorkAuthorization = fn
|
||||
}
|
||||
|
||||
// SetAccountEffectiveAuthorization binds the current account-state guard. It is
|
||||
// independent of the optional domain project provider.
|
||||
func (m *Manager) SetAccountEffectiveAuthorization(fn func(context.Context, string) (bool, error), allowImplicitLocal ...bool) {
|
||||
m.accountEffectiveAuthorization = fn
|
||||
m.events.accountGuardEnabled = fn != nil
|
||||
if len(allowImplicitLocal) > 0 {
|
||||
m.accountAllowImplicitLocal = allowImplicitLocal[0]
|
||||
m.events.allowImplicitLocal = allowImplicitLocal[0]
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) authorizeAccountEffective(ctx context.Context, owner string) error {
|
||||
if m.accountEffectiveAuthorization == nil {
|
||||
return nil
|
||||
}
|
||||
ok, err := m.accountEffectiveAuthorization(ctx, owner)
|
||||
if err != nil {
|
||||
m.cancelActiveOwnerRuns(owner)
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
m.cancelActiveOwnerRuns(owner)
|
||||
return store.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) cancelActiveOwnerRuns(owner string) {
|
||||
m.mu.Lock()
|
||||
var cancels []context.CancelFunc
|
||||
for _, h := range m.active {
|
||||
if h.owner == owner && h.cancel != nil {
|
||||
cancels = append(cancels, h.cancel)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
for _, cancel := range cancels {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
func (m *Manager) authorizeProjectWork(ctx context.Context, owner string, id core.ID) error {
|
||||
if m.projectWorkAuthorization == nil {
|
||||
return nil
|
||||
@@ -231,6 +342,13 @@ func (m *Manager) authorizeProjectWork(ctx context.Context, owner string, id cor
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) authorizeRunWork(ctx context.Context, owner string, id core.ID) error {
|
||||
if err := m.authorizeAccountEffective(ctx, owner); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.authorizeProjectWork(ctx, owner, id)
|
||||
}
|
||||
|
||||
// SetKnowledgeProjectScope binds host project filters to the provider's trusted
|
||||
// canonical domain metadata without widening the current conversation scope.
|
||||
func (m *Manager) SetKnowledgeProjectScope(fn func(context.Context, string, core.ID) (core.ID, error)) {
|
||||
@@ -445,11 +563,20 @@ func conversationMode(m *Manager, owner, id string) string {
|
||||
func (c *runCtx) PermissionMode() string { return c.permissionMode }
|
||||
|
||||
func (c *runCtx) ExitPlanMode() error {
|
||||
c.permissionMode = ""
|
||||
if c.mgr == nil || c.mgr.st == nil {
|
||||
c.permissionMode = ""
|
||||
return nil
|
||||
}
|
||||
return c.mgr.st.SetPermissionMode(c.run.Owner, c.run.ConversationID, "default")
|
||||
var err error
|
||||
if c.mgr.accountEffectiveAuthorization != nil {
|
||||
err = c.mgr.st.SetPermissionModeAccountChecked(c.run.Owner, c.run.ConversationID, "default", c.mgr.accountAllowImplicitLocal)
|
||||
} else {
|
||||
err = c.mgr.st.SetPermissionMode(c.run.Owner, c.run.ConversationID, "default")
|
||||
}
|
||||
if err == nil {
|
||||
c.permissionMode = ""
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) rememberSessionAllow(conv, tool string) {
|
||||
@@ -625,11 +752,17 @@ func (c *runCtx) Memories() []core.MemoryEntry {
|
||||
}
|
||||
|
||||
func (c *runCtx) Emit(typ core.StreamEventType, payload any) error {
|
||||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, typ, payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *runCtx) SetAssistantParts(parts []core.Part) error {
|
||||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
c.mgr.mu.Lock()
|
||||
c.h.assistant = parts
|
||||
c.mgr.mu.Unlock()
|
||||
@@ -637,16 +770,21 @@ func (c *runCtx) SetAssistantParts(parts []core.Part) error {
|
||||
}
|
||||
|
||||
func (c *runCtx) AskHITL(card core.HITLCard) (answer core.HITLResponse, err error) {
|
||||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return core.HITLResponse{}, err
|
||||
}
|
||||
card.ID = store.NewID("hitl")
|
||||
card.RunID = c.run.ID
|
||||
card.ConversationID = c.run.ConversationID
|
||||
if card.CreatedAt == "" {
|
||||
card.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
if err := c.mgr.st.SaveHITLCard(c.run.Owner, &card); err != nil {
|
||||
if err := c.mgr.saveHITLCardForEffectiveAccount(c.run.Owner, &card); err != nil {
|
||||
return core.HITLResponse{}, err
|
||||
}
|
||||
if err := c.mgr.setRunStatus(&c.run, core.RunWaitingHITL, c.run.Phase); err != nil {
|
||||
return core.HITLResponse{}, err
|
||||
}
|
||||
c.mgr.setRunStatus(&c.run, core.RunWaitingHITL, c.run.Phase)
|
||||
if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvHITLRequest, card); err != nil {
|
||||
return core.HITLResponse{}, err
|
||||
}
|
||||
@@ -666,7 +804,7 @@ func (c *runCtx) AskHITL(card core.HITLCard) (answer core.HITLResponse, err erro
|
||||
if card.AllowFreeInput {
|
||||
proj.Body = append(proj.Body, core.UINode{Type: core.UIMarkdown, Markdown: "_可在输入框自由输入回应。_"})
|
||||
}
|
||||
if _, err := c.mgr.cards.Create(c.run.Owner, proj, "hitl:"+card.ID); err != nil {
|
||||
if _, err := c.mgr.createCardForEffectiveAccount(c.run.Owner, proj, "hitl:"+card.ID); err != nil {
|
||||
return core.HITLResponse{}, err
|
||||
}
|
||||
}
|
||||
@@ -678,13 +816,16 @@ func (c *runCtx) AskHITL(card core.HITLCard) (answer core.HITLResponse, err erro
|
||||
// 排队,取消语境下重取失败由取消路径收口)。
|
||||
c.mgr.adm.release(c.mgr.st, string(c.run.ID))
|
||||
defer func() {
|
||||
if acquireErr := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner); acquireErr != nil {
|
||||
if acquireErr := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner, c.mgr.accountEffectiveAuthorization != nil, c.mgr.accountAllowImplicitLocal); acquireErr != nil {
|
||||
log.Printf("[runmgr] admission reacquire after HITL %s: %v", c.run.ID, acquireErr)
|
||||
if err == nil {
|
||||
err = acquireErr
|
||||
}
|
||||
}
|
||||
// Permission can change during both confirmation and admission waiting.
|
||||
// Every HITL caller receives the live decision before it resumes work.
|
||||
if err == nil {
|
||||
err = c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID)
|
||||
err = c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID)
|
||||
}
|
||||
}()
|
||||
return waitHITL(c.ctx, c.h.hitlCh, timeout)
|
||||
@@ -731,9 +872,18 @@ func (c *runCtx) Subhint() string { return c.subHint }
|
||||
func (c *runCtx) Submodel() string { return c.subModel }
|
||||
|
||||
func (c *runCtx) UpdatePlan(p core.PlanSnapshot) error {
|
||||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
prev, _ := c.mgr.st.GetPlan(c.run.ConversationID)
|
||||
p.PlanID = c.run.ConversationID
|
||||
if err := c.mgr.st.SavePlan(c.run.ConversationID, p); err != nil {
|
||||
var saveErr error
|
||||
if c.mgr.accountEffectiveAuthorization != nil {
|
||||
saveErr = c.mgr.st.SavePlanAccountChecked(c.run.Owner, c.run.ConversationID, p, c.mgr.accountAllowImplicitLocal)
|
||||
} else {
|
||||
saveErr = c.mgr.st.SavePlan(c.run.ConversationID, p)
|
||||
}
|
||||
if err := saveErr; err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvPlanUpdate, core.PlanUpdatePayload{Plan: p})
|
||||
@@ -762,9 +912,18 @@ func (c *runCtx) Plan() (core.PlanSnapshot, bool) {
|
||||
}
|
||||
|
||||
func (c *runCtx) UpdateTodos(l core.TodoList) error {
|
||||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
prev, _ := c.mgr.st.GetTodos(c.run.ConversationID)
|
||||
l.ConversationID = c.run.ConversationID
|
||||
if err := c.mgr.st.SaveTodos(c.run.ConversationID, l); err != nil {
|
||||
var saveErr error
|
||||
if c.mgr.accountEffectiveAuthorization != nil {
|
||||
saveErr = c.mgr.st.SaveTodosAccountChecked(c.run.Owner, c.run.ConversationID, l, c.mgr.accountAllowImplicitLocal)
|
||||
} else {
|
||||
saveErr = c.mgr.st.SaveTodos(c.run.ConversationID, l)
|
||||
}
|
||||
if err := saveErr; err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvTodoUpdate, core.TodoUpdatePayload{List: l})
|
||||
@@ -780,6 +939,9 @@ func (c *runCtx) UpdateTodos(l core.TodoList) error {
|
||||
// (scope=project 工具参数),补全 ProjectID、ConversationID 保持空;其余照旧
|
||||
// 归属当前会话。
|
||||
func (c *runCtx) CreateCard(card core.ActivityCard) (core.ActivityCard, error) {
|
||||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return core.ActivityCard{}, err
|
||||
}
|
||||
if c.mgr.cards == nil {
|
||||
return card, nil
|
||||
}
|
||||
@@ -788,14 +950,17 @@ func (c *runCtx) CreateCard(card core.ActivityCard) (core.ActivityCard, error) {
|
||||
}
|
||||
card.ProjectID = c.run.ProjectID
|
||||
card.SourceRunID = c.run.ID
|
||||
return c.mgr.cards.Create(c.run.Owner, card, "")
|
||||
return c.mgr.createCardForEffectiveAccount(c.run.Owner, card, "")
|
||||
}
|
||||
|
||||
func (c *runCtx) SetCardStatus(id string, status core.CardStatus, tone, note string) error {
|
||||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.mgr.cards == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := c.mgr.cards.SetStatus(c.run.Owner, id, status, tone, note)
|
||||
_, err := c.mgr.setCardStatusForEffectiveAccount(c.run.Owner, id, status, tone, note)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -831,18 +996,24 @@ func (c *runCtx) BeginStreamCard(card core.ActivityCard) (core.ActivityCard, err
|
||||
|
||||
// AppendStreamCard:card_append 工具入口——向活跃流式卡追加正文(事件节流)。
|
||||
func (c *runCtx) AppendStreamCard(text string) error {
|
||||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
c.mgr.mu.Lock()
|
||||
id := c.h.streamCardID
|
||||
c.mgr.mu.Unlock()
|
||||
if id == "" {
|
||||
return errors.New("card: no active stream card (card_begin first)")
|
||||
}
|
||||
return c.mgr.cards.Append(c.run.Owner, string(id), text)
|
||||
return c.mgr.appendCardForEffectiveAccount(c.run.Owner, string(id), text)
|
||||
}
|
||||
|
||||
// CommitStreamCard:card_commit 工具入口——活跃流式卡置 done、登记锚点回填
|
||||
// 并清空活跃指针。
|
||||
func (c *runCtx) CommitStreamCard() (core.ActivityCard, error) {
|
||||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return core.ActivityCard{}, err
|
||||
}
|
||||
c.mgr.mu.Lock()
|
||||
id := c.h.streamCardID
|
||||
c.h.streamCardID = ""
|
||||
@@ -850,7 +1021,7 @@ func (c *runCtx) CommitStreamCard() (core.ActivityCard, error) {
|
||||
if id == "" {
|
||||
return core.ActivityCard{}, errors.New("card: no active stream card (card_begin first)")
|
||||
}
|
||||
card, err := c.mgr.cards.SetStatus(c.run.Owner, string(id), core.CardDone, "ok", "")
|
||||
card, err := c.mgr.setCardStatusForEffectiveAccount(c.run.Owner, string(id), core.CardDone, "ok", "")
|
||||
if err != nil {
|
||||
return card, err
|
||||
}
|
||||
@@ -887,11 +1058,17 @@ func (c *runCtx) Artifact(id string) (core.Artifact, []byte, bool) {
|
||||
}
|
||||
|
||||
func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
|
||||
if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return core.Artifact{}, err
|
||||
}
|
||||
art := core.Artifact{ProjectID: c.run.ProjectID, Owner: c.run.Owner, Name: name, Kind: kind, MIME: mime, SourceRun: c.run.ID}
|
||||
if err := c.mgr.st.CreateArtifact(&art, content); err != nil {
|
||||
var writeErr error
|
||||
if c.mgr.accountEffectiveAuthorization != nil {
|
||||
writeErr = c.mgr.st.CreateArtifactAccountChecked(c.run.Owner, &art, content, c.mgr.accountAllowImplicitLocal)
|
||||
} else {
|
||||
writeErr = c.mgr.st.CreateArtifact(&art, content)
|
||||
}
|
||||
if err := writeErr; err != nil {
|
||||
return art, err
|
||||
}
|
||||
if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvArtifactCreated, core.ArtifactPayload{Artifact: art}); err != nil {
|
||||
@@ -904,7 +1081,21 @@ func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Ar
|
||||
// 抽不到则跳过,避免把 ZIP 字节当文本索引。
|
||||
if kind == "document" && len(content) < 200000 {
|
||||
if text := indexTextOf(name, mime, content); text != "" {
|
||||
if c.mgr.indexer != nil {
|
||||
if c.mgr.accountEffectiveAuthorization != nil {
|
||||
if c.mgr.indexer != nil {
|
||||
checked, ok := c.mgr.indexer.(interface {
|
||||
IndexDocumentAccountChecked(context.Context, core.ID, core.ID, string, string, string, string, bool) error
|
||||
})
|
||||
if !ok {
|
||||
return art, fmt.Errorf("document indexer does not support account fencing")
|
||||
}
|
||||
if err := checked.IndexDocumentAccountChecked(c.ctx, c.run.Owner, c.run.ProjectID, "artifact", name, art.ID, text, c.mgr.accountAllowImplicitLocal); err != nil {
|
||||
return art, err
|
||||
}
|
||||
} else if err := c.mgr.st.AddKnowledgeAccountChecked(string(c.run.Owner), c.mgr.accountAllowImplicitLocal, core.KnowledgeDoc{Owner: c.run.Owner, ProjectID: c.run.ProjectID, Source: "artifact", Title: name, Ref: art.ID, Chunk: text}); err != nil {
|
||||
return art, err
|
||||
}
|
||||
} else if c.mgr.indexer != nil {
|
||||
_ = c.mgr.indexer.IndexDocument(c.run.Owner, c.run.ProjectID, "artifact", name, art.ID, text)
|
||||
} else {
|
||||
_ = c.mgr.st.AddKnowledge(core.KnowledgeDoc{Owner: c.run.Owner, ProjectID: c.run.ProjectID, Source: "artifact", Title: name, Ref: art.ID, Chunk: text})
|
||||
@@ -999,7 +1190,7 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
|
||||
// ExecuteTool 执行画像策略:deny→错误;confirm→HITL confirm 卡片;执行→截断转工件。
|
||||
func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
|
||||
}
|
||||
policy := c.prof.Tools
|
||||
@@ -1022,7 +1213,7 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
if err != nil {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "hitl aborted: " + err.Error()}
|
||||
}
|
||||
if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
|
||||
}
|
||||
if resp.ChoiceID == "session" {
|
||||
@@ -1031,7 +1222,7 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"}
|
||||
}
|
||||
}
|
||||
if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
|
||||
}
|
||||
// EXT-14:命令即工具——命中命令投影时经命令总分发(Entrance=agent、
|
||||
@@ -1049,6 +1240,9 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
ID: call.Name, Params: params, Entrance: "agent",
|
||||
Actor: c.run.Owner, Owner: c.run.Owner, ConversationID: c.run.ConversationID,
|
||||
})
|
||||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
|
||||
}
|
||||
out, _ := json.Marshal(resp.Payload)
|
||||
if resp.Card && resp.State == "accepted" {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "ok",
|
||||
@@ -1063,6 +1257,9 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
}
|
||||
tc := &toolCtx{rc: c, callID: call.CallID}
|
||||
res := c.mgr.registry.Execute(tc, call)
|
||||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
|
||||
}
|
||||
// 工具产物回填(交付物卡片根因修复):注册表工具执行期经 rc.WriteArtifact
|
||||
// 写入的工件(write_artifact / fs_write_file / register_artifact / 未来执行
|
||||
// 原语产物)挂到结果上——llm.go 依 res.Artifact 生成对话内 artifact_ref 卡片。
|
||||
@@ -1108,7 +1305,10 @@ func (t *toolCtx) WriteArtifact(name, kind, mime string, content []byte) (core.A
|
||||
}
|
||||
return art, err
|
||||
}
|
||||
func (t *toolCtx) Context() context.Context { return t.rc.Context() }
|
||||
func (t *toolCtx) Context() context.Context { return t.rc.Context() }
|
||||
func (t *toolCtx) CheckExternalEffect() error {
|
||||
return t.rc.mgr.authorizeRunWork(t.rc.ctx, t.rc.run.Owner, t.rc.run.ProjectID)
|
||||
}
|
||||
func (t *toolCtx) OwnerID() string { return t.rc.run.Owner }
|
||||
func (t *toolCtx) WorkspaceRoot() string { return t.rc.mgr.workspaceRoot }
|
||||
func (t *toolCtx) SettingBool(key string, def bool) bool { return t.rc.eff.GetBool(key, def) }
|
||||
@@ -1232,7 +1432,7 @@ func selectConversationRunner(m *Manager, eff core.EffectiveSettings, launch *wo
|
||||
// attachments 为已解析的附件文档(server 层经 ingest 处理)。
|
||||
// inputs 是同一批附件的原件,落到会话工作区 inputs/,供模板和仿制使用。
|
||||
func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile) (core.Run, error) {
|
||||
if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil {
|
||||
if err := m.authorizeRunWork(context.Background(), conv.Owner, conv.ProjectID); err != nil {
|
||||
return core.Run{}, err
|
||||
}
|
||||
if err := m.claimConversation(conv.ID); err != nil {
|
||||
@@ -1244,7 +1444,7 @@ func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.
|
||||
// StartWorkflowRun 启动一条绑定工作流的会话 run。定义在这里拷贝,
|
||||
// 之后插件卸载不会改到已经在跑的这份。
|
||||
func (m *Manager) StartWorkflowRun(conv core.Conversation, userMessage core.Message, def core.WorkflowDef, slots map[string]string, missing []workflow.SlotQuestion) (core.Run, error) {
|
||||
if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil {
|
||||
if err := m.authorizeRunWork(context.Background(), conv.Owner, conv.ProjectID); err != nil {
|
||||
return core.Run{}, err
|
||||
}
|
||||
if err := m.claimConversation(conv.ID); err != nil {
|
||||
@@ -1280,14 +1480,24 @@ func workflowScriptBodies(reg *workflow.Registry, def core.WorkflowDef) map[stri
|
||||
}
|
||||
|
||||
// ClaimConversation 抢占会话槽位。忙则返回 ErrConversationBusy,此时调用方不应写消息。
|
||||
func (m *Manager) ClaimConversation(conversationID string) error {
|
||||
func (m *Manager) ClaimConversation(conversationID string, actor ...string) error {
|
||||
if err := m.claimConversation(conversationID); err != nil {
|
||||
return err
|
||||
}
|
||||
// 执行所有权(U5/KTD4):PG 档在内存槽位之上叠加 DB 租约——跨实例的
|
||||
// 同会话争抢由库裁决;接管递增 epoch 供结算围栏。失败时回退内存槽位。
|
||||
if m.st.Dialect() == "postgres" {
|
||||
epoch, err := m.st.TryClaimExecution(conversationID, m.InstanceID(), "", 15*60)
|
||||
var epoch int64
|
||||
var err error
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
if len(actor) == 0 || actor[0] == "" {
|
||||
m.releaseConversation(conversationID)
|
||||
return store.ErrAccountForbidden
|
||||
}
|
||||
epoch, err = m.st.TryClaimExecutionAccountChecked(actor[0], conversationID, m.InstanceID(), "", 15*60, m.accountAllowImplicitLocal)
|
||||
} else {
|
||||
epoch, err = m.st.TryClaimExecution(conversationID, m.InstanceID(), "", 15*60)
|
||||
}
|
||||
if err != nil {
|
||||
m.releaseConversation(conversationID)
|
||||
if errors.Is(err, store.ErrExecutionBusy) {
|
||||
@@ -1356,7 +1566,7 @@ func (m *Manager) claimConversation(conversationID string) error {
|
||||
}
|
||||
|
||||
func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile, launch *workflow.Launch) (core.Run, error) {
|
||||
if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil {
|
||||
if err := m.authorizeRunWork(context.Background(), conv.Owner, conv.ProjectID); err != nil {
|
||||
m.releaseConversation(conv.ID)
|
||||
return core.Run{}, err
|
||||
}
|
||||
@@ -1366,19 +1576,42 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa
|
||||
ID: store.NewID("run"), ConversationID: conv.ID, ProjectID: conv.ProjectID, Owner: conv.Owner,
|
||||
Kind: core.RunConversation, ProfileID: prof.ID, Status: core.RunPending, Phase: core.PhaseThinking,
|
||||
}
|
||||
if err := m.st.CreateRun(&run); err != nil {
|
||||
var createErr error
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
createErr = m.st.CreateRunAccountChecked(&run, m.accountAllowImplicitLocal)
|
||||
} else {
|
||||
createErr = m.st.CreateRun(&run)
|
||||
}
|
||||
if err := createErr; err != nil {
|
||||
m.releaseConversation(conv.ID)
|
||||
return run, err
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
h := &runHandle{cancel: cancel, hitlCh: make(chan hitlAnswer, 1), started: time.Now()}
|
||||
h := &runHandle{owner: run.Owner, cancel: cancel, hitlCh: make(chan hitlAnswer, 1), started: time.Now()}
|
||||
m.mu.Lock()
|
||||
m.active[run.ID] = h
|
||||
m.mu.Unlock()
|
||||
|
||||
runner := selectConversationRunner(m, eff, launch)
|
||||
inputPaths, _ := m.stageInputs(conv, eff, inputs)
|
||||
PersistInputTexts(m.workspaceRoot, conv.Owner, conv.ID, attachments)
|
||||
var checkInputEffect func(context.Context) error
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
checkInputEffect = func(checkCtx context.Context) error {
|
||||
return m.authorizeAccountEffective(checkCtx, conv.Owner)
|
||||
}
|
||||
}
|
||||
inputPaths, inputErr := m.stageInputsChecked(ctx, conv, eff, inputs, checkInputEffect)
|
||||
if inputErr == nil {
|
||||
inputErr = m.persistInputTextsChecked(ctx, conv.Owner, conv.ID, attachments, checkInputEffect)
|
||||
}
|
||||
if inputErr != nil {
|
||||
cancel()
|
||||
m.mu.Lock()
|
||||
delete(m.active, run.ID)
|
||||
m.mu.Unlock()
|
||||
m.releaseConversation(conv.ID)
|
||||
m.setRunStatus(&run, core.RunFailed, core.PhaseDone)
|
||||
return run, inputErr
|
||||
}
|
||||
inputPaths = ListWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, inputPaths)
|
||||
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch, files: filestate.New(), permissionMode: conversationMode(m, run.Owner, run.ConversationID)}
|
||||
// 材料整理(附件即保证):无文字/大材料发送瞬间预启动,说明注入第 0 回合;
|
||||
@@ -1389,7 +1622,7 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa
|
||||
go func() {
|
||||
// 执行许可(U3,R8):排队有界;获取失败(队列满/取消/存储故障)以
|
||||
// failed 收口——不执行任何模型调用,副作用无从产生。
|
||||
if err := m.adm.acquire(rc.ctx, m.st, run.ID, run.Owner); err != nil {
|
||||
if err := m.adm.acquire(rc.ctx, m.st, run.ID, run.Owner, m.accountEffectiveAuthorization != nil, m.accountAllowImplicitLocal); err != nil {
|
||||
log.Printf("[runmgr] admission denied run %s: %v", run.ID, err)
|
||||
m.setRunStatus(&run, core.RunFailed, core.PhaseThinking)
|
||||
if uErr, ok := err.(error); ok {
|
||||
@@ -1432,7 +1665,14 @@ func (m *Manager) execute(rc *runCtx, runner runtime.Runner) {
|
||||
m.mu.Unlock()
|
||||
m.deliverNotices(rc.run.ConversationID)
|
||||
}()
|
||||
m.setRunStatus(&rc.run, core.RunRunning, core.PhaseThinking)
|
||||
if err := m.authorizeRunWork(rc.ctx, rc.run.Owner, rc.run.ProjectID); err != nil {
|
||||
m.finishRun(rc, core.RunFailed, core.Usage{}, &core.ErrorPart{Code: "run.account_inactive", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := m.setRunStatus(&rc.run, core.RunRunning, core.PhaseThinking); err != nil {
|
||||
m.finishRun(rc, core.RunFailed, core.Usage{}, &core.ErrorPart{Code: "run.account_inactive", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseThinking})
|
||||
|
||||
execErr := func() (err error) {
|
||||
@@ -1457,6 +1697,14 @@ func (m *Manager) execute(rc *runCtx, runner runtime.Runner) {
|
||||
}
|
||||
|
||||
func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage, errPart *core.ErrorPart) {
|
||||
accountDenied := m.authorizeAccountEffective(rc.ctx, rc.run.Owner)
|
||||
if accountDenied != nil {
|
||||
status = core.RunFailed
|
||||
errPart = &core.ErrorPart{Code: "run.account_inactive", Message: accountDenied.Error()}
|
||||
if rc.h.cancel != nil {
|
||||
rc.h.cancel()
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
usage.PromptTokens = rc.h.promptTokens
|
||||
usage.CompletionTokens = rc.h.completionTokens
|
||||
@@ -1481,13 +1729,16 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage,
|
||||
m.mu.Lock()
|
||||
parts := append([]core.Part{}, rc.h.assistant...)
|
||||
m.mu.Unlock()
|
||||
if accountDenied != nil {
|
||||
parts = nil
|
||||
}
|
||||
if errPart != nil {
|
||||
parts = append(parts, core.Part{Type: core.PartError, Err: errPart})
|
||||
}
|
||||
var msg *core.Message
|
||||
if len(parts) > 0 {
|
||||
msg = &core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: parts}
|
||||
if group, index, ok := m.st.TakeRegenPending(rc.run.ConversationID); ok {
|
||||
if group, index, ok := m.st.PeekRegenPending(rc.run.ConversationID); ok {
|
||||
msg.VariantGroup = group
|
||||
msg.VariantIndex = index
|
||||
}
|
||||
@@ -1518,7 +1769,7 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage,
|
||||
for _, id := range cardIDs {
|
||||
if card, err := m.cards.Get(rc.run.Owner, string(id)); err == nil {
|
||||
card.AnchorMessageID = msg.ID
|
||||
_ = m.cards.Update(rc.run.Owner, card)
|
||||
_ = m.updateCardForEffectiveAccount(rc.run.Owner, card)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1535,7 +1786,7 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage,
|
||||
if status != core.RunCompleted {
|
||||
st = core.CardFailed
|
||||
}
|
||||
if card, err := m.cards.SetStatus(rc.run.Owner, string(streamID), st, "warn", "未正常收尾"); err == nil {
|
||||
if card, err := m.setCardStatusForEffectiveAccount(rc.run.Owner, string(streamID), st, "warn", "未正常收尾"); err == nil {
|
||||
m.mu.Lock()
|
||||
rc.h.submittedCards = append(rc.h.submittedCards, card.ID)
|
||||
m.mu.Unlock()
|
||||
@@ -1586,10 +1837,13 @@ func (m *Manager) failSettle(rc *runCtx, cause error) {
|
||||
}
|
||||
|
||||
// setRunStatus 更新并广播 phase(状态不单独出事件,phase 事件携带)。
|
||||
func (m *Manager) setRunStatus(r *core.Run, status core.RunStatus, phase core.RunPhase) {
|
||||
func (m *Manager) setRunStatus(r *core.Run, status core.RunStatus, phase core.RunPhase) error {
|
||||
r.Status = status
|
||||
r.Phase = phase
|
||||
m.st.UpdateRun(r)
|
||||
if m.accountEffectiveAuthorization != nil && status != core.RunFailed && status != core.RunCancelled && status != core.RunInterrupted {
|
||||
return m.st.UpdateRunAccountChecked(string(r.Owner), r, m.accountAllowImplicitLocal)
|
||||
}
|
||||
return m.st.UpdateRun(r)
|
||||
}
|
||||
|
||||
// emit 经批处理器持久化事件并广播;返回 seq(缓冲中的增量返回 0)。
|
||||
@@ -1600,7 +1854,11 @@ func (m *Manager) emit(conversationID, runID string, typ core.StreamEventType, p
|
||||
// settleRunWithFence(U5):PG 档以本实例登记的 epoch 做围栏结算。
|
||||
func (m *Manager) settleRunWithFence(rc *runCtx, msg *core.Message) (updated, fenced bool, err error) {
|
||||
if m.st.Dialect() != "postgres" {
|
||||
updated, err = m.st.SettleRun(&rc.run, msg)
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
updated, err = m.st.SettleRunAccountChecked(&rc.run, msg, m.accountAllowImplicitLocal)
|
||||
} else {
|
||||
updated, err = m.st.SettleRun(&rc.run, msg)
|
||||
}
|
||||
return updated, false, err
|
||||
}
|
||||
m.execMu.Lock()
|
||||
@@ -1612,6 +1870,9 @@ func (m *Manager) settleRunWithFence(rc *runCtx, msg *core.Message) (updated, fe
|
||||
epoch = es.Epoch
|
||||
}
|
||||
}
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
return m.st.SettleRunFencedAccountChecked(&rc.run, msg, m.InstanceID(), epoch, m.accountAllowImplicitLocal)
|
||||
}
|
||||
return m.st.SettleRunFenced(&rc.run, msg, m.InstanceID(), epoch)
|
||||
}
|
||||
|
||||
@@ -1685,7 +1946,7 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
|
||||
if m.projectWorkAuthorization != nil && (run.Owner != owner || run.ConversationID != card.ConversationID) {
|
||||
return "", false, store.ErrNotFound
|
||||
}
|
||||
if err := m.authorizeProjectWork(context.Background(), run.Owner, run.ProjectID); err != nil {
|
||||
if err := m.authorizeRunWork(context.Background(), run.Owner, run.ProjectID); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
// U7 双进程验收实测缺陷修复:run 不在本进程执行时不得先落 resolve——
|
||||
@@ -1732,31 +1993,27 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
|
||||
}
|
||||
}
|
||||
if pivot {
|
||||
if err := m.authorizeProjectWork(context.Background(), run.Owner, run.ProjectID); err != nil {
|
||||
if err := m.authorizeRunWork(context.Background(), run.Owner, run.ProjectID); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
_ = m.CancelRun(run.ID)
|
||||
m.st.ResolveHITLCard(owner, cardID, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
|
||||
m.resolveHITLCardForEffectiveAccount(owner, cardID, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
|
||||
m.emit(run.ConversationID, run.ID, core.EvHITLResolved, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
|
||||
if m.cards != nil {
|
||||
_ = m.cards.ResolveByRef(owner, "hitl:"+cardID, "", answer)
|
||||
}
|
||||
_ = m.resolveProjectedCardForEffectiveAccount(owner, "hitl:"+cardID, "", answer)
|
||||
_ = pivotReason
|
||||
return trimmed, true, nil
|
||||
}
|
||||
}
|
||||
if err := m.authorizeProjectWork(context.Background(), run.Owner, run.ProjectID); err != nil {
|
||||
if err := m.authorizeRunWork(context.Background(), run.Owner, run.ProjectID); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
resolution := core.HITLResolvedPayload{CardID: core.ID(cardID), Answer: answer, Kind: kind}
|
||||
if err := m.st.ResolveHITLCard(owner, cardID, resolution); err != nil {
|
||||
if err := m.resolveHITLCardForEffectiveAccount(owner, cardID, resolution); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
m.emit(run.ConversationID, run.ID, core.EvHITLResolved, resolution)
|
||||
// 投影卡同步销项(双现;幂等)。
|
||||
if m.cards != nil {
|
||||
_ = m.cards.ResolveByRef(owner, "hitl:"+cardID, resp.ChoiceID, answer)
|
||||
}
|
||||
_ = m.resolveProjectedCardForEffectiveAccount(owner, "hitl:"+cardID, resp.ChoiceID, answer)
|
||||
|
||||
m.mu.Lock()
|
||||
h, ok := m.active[run.ID]
|
||||
@@ -1772,7 +2029,13 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
|
||||
// 恢复运行状态。
|
||||
if run.Status == core.RunWaitingHITL {
|
||||
run.Status = core.RunRunning
|
||||
m.st.UpdateRun(&run)
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
if err := m.st.UpdateRunAccountChecked(string(run.Owner), &run, m.accountAllowImplicitLocal); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
} else if err := m.st.UpdateRun(&run); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
@@ -1780,6 +2043,9 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
|
||||
// ---- 子代理(任务树)----
|
||||
|
||||
func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, agentID, name, task, hint string) (core.AgentResultPayload, error) {
|
||||
if err := m.authorizeRunWork(parentCtx, parent.Owner, parent.ProjectID); err != nil {
|
||||
return core.AgentResultPayload{}, err
|
||||
}
|
||||
rc, runner, err := m.startChild(parentCtx, parent, agentID, name, task, hint, false)
|
||||
if err != nil {
|
||||
return core.AgentResultPayload{}, err
|
||||
@@ -1789,11 +2055,11 @@ func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, agen
|
||||
// 取消语境下重取失败仅记录,run 由取消路径收口)。
|
||||
m.adm.release(m.st, string(parent.ID))
|
||||
defer func() {
|
||||
if err := m.adm.acquire(parentCtx, m.st, string(parent.ID), parent.Owner); err != nil {
|
||||
if err := m.adm.acquire(parentCtx, m.st, string(parent.ID), parent.Owner, m.accountEffectiveAuthorization != nil, m.accountAllowImplicitLocal); err != nil {
|
||||
log.Printf("[runmgr] admission reacquire after child %s: %v", parent.ID, err)
|
||||
}
|
||||
}()
|
||||
if err := m.adm.acquire(parentCtx, m.st, rc.run.ID, rc.run.Owner); err != nil {
|
||||
if err := m.adm.acquire(parentCtx, m.st, rc.run.ID, rc.run.Owner, m.accountEffectiveAuthorization != nil, m.accountAllowImplicitLocal); err != nil {
|
||||
m.setRunStatus(&rc.run, core.RunFailed, rc.run.Phase)
|
||||
m.st.UpdateRun(&core.Run{ID: rc.run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: err.Error()}})
|
||||
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.admission_denied", Message: "子代理执行许可排队已满或已取消"})
|
||||
@@ -1811,6 +2077,9 @@ func (m *Manager) startChild(parentCtx context.Context, parent core.Run, agentID
|
||||
|
||||
// startChildOpts 在 startChild 之上支持系统派发选项(模型档等)。
|
||||
func (m *Manager) startChildOpts(parentCtx context.Context, parent core.Run, o bgSpawnOpts, detach bool) (*runCtx, runtime.Runner, error) {
|
||||
if err := m.authorizeRunWork(parentCtx, parent.Owner, parent.ProjectID); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
agentID, name, task, hint := o.agentID, o.name, o.task, o.hint
|
||||
if parent.Kind == core.RunSubagent {
|
||||
return nil, nil, fmt.Errorf("子代理不能再派发子代理")
|
||||
@@ -1823,11 +2092,26 @@ func (m *Manager) startChildOpts(parentCtx context.Context, parent core.Run, o b
|
||||
ProjectID: parent.ProjectID, Owner: parent.Owner, Kind: core.RunSubagent, ProfileID: parent.ProfileID,
|
||||
Status: core.RunPending, Phase: core.PhaseThinking, AgentName: name,
|
||||
}
|
||||
if err := m.st.CreateRun(&child); err != nil {
|
||||
var createErr error
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
createErr = m.st.CreateRunAccountChecked(&child, m.accountAllowImplicitLocal)
|
||||
} else {
|
||||
createErr = m.st.CreateRun(&child)
|
||||
}
|
||||
if err := createErr; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// 独立转录(N2 WP5):派生请求落盘,续跑的重建凭据。
|
||||
m.writeSubagentRequest(child, agentID, name, task, hint, detach)
|
||||
var checkChildEffect func(context.Context) error
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
checkChildEffect = func(checkCtx context.Context) error {
|
||||
return m.authorizeAccountEffective(checkCtx, child.Owner)
|
||||
}
|
||||
}
|
||||
if err := m.writeSubagentRequestChecked(parentCtx, child, agentID, name, task, hint, detach, checkChildEffect); err != nil {
|
||||
m.setRunStatus(&child, core.RunFailed, core.PhaseDone)
|
||||
return nil, nil, err
|
||||
}
|
||||
m.emit(parent.ConversationID, parent.ID, core.EvAgentSpawn, core.AgentSpawnPayload{AgentRun: child.ID, Name: name, Task: task})
|
||||
|
||||
base := parentCtx
|
||||
@@ -1835,7 +2119,7 @@ func (m *Manager) startChildOpts(parentCtx context.Context, parent core.Run, o b
|
||||
base = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(base)
|
||||
h := &runHandle{cancel: cancel, hitlCh: make(chan hitlAnswer, 1), started: time.Now()}
|
||||
h := &runHandle{owner: child.Owner, cancel: cancel, hitlCh: make(chan hitlAnswer, 1), started: time.Now()}
|
||||
m.mu.Lock()
|
||||
m.active[child.ID] = h
|
||||
m.mu.Unlock()
|
||||
@@ -1843,7 +2127,13 @@ func (m *Manager) startChildOpts(parentCtx context.Context, parent core.Run, o b
|
||||
prof, _ := m.profiles.Get(parent.Owner, parent.ProfileID)
|
||||
eff := m.settings.Effective(parent.Owner, parent.ProjectID)
|
||||
rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint, subAgent: agentID, subModel: o.model, files: filestate.New(), permissionMode: conversationMode(m, child.Owner, child.ConversationID)}
|
||||
m.setRunStatus(&rc.run, core.RunRunning, core.PhaseActing)
|
||||
if err := m.setRunStatus(&rc.run, core.RunRunning, core.PhaseActing); err != nil {
|
||||
cancel()
|
||||
m.mu.Lock()
|
||||
delete(m.active, child.ID)
|
||||
m.mu.Unlock()
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
runner := m.subRunner
|
||||
if eff.GetBool("runtime.mock", false) || runner == nil {
|
||||
@@ -1855,14 +2145,24 @@ func (m *Manager) startChildOpts(parentCtx context.Context, parent core.Run, o b
|
||||
// finishChild 执行子 run、落库并汇总。emitResult 为假时不发 agent.result,
|
||||
// 留给后台投递成功之后再发。
|
||||
func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool) (core.AgentResultPayload, error) {
|
||||
execErr := func() (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("subagent panic: %v", r)
|
||||
}
|
||||
execErr := m.authorizeRunWork(rc.ctx, rc.run.Owner, rc.run.ProjectID)
|
||||
if execErr == nil {
|
||||
execErr = func() (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("subagent panic: %v", r)
|
||||
}
|
||||
}()
|
||||
return runner.Execute(rc)
|
||||
}()
|
||||
return runner.Execute(rc)
|
||||
}()
|
||||
}
|
||||
accountDenied := m.authorizeAccountEffective(rc.ctx, rc.run.Owner)
|
||||
if accountDenied != nil {
|
||||
execErr = accountDenied
|
||||
if rc.h.cancel != nil {
|
||||
rc.h.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// 子代理记账(N3 WP6):token 用量随子 run 入账(此前只有 DurationMs)。
|
||||
usage := core.Usage{DurationMs: float64(time.Since(rc.h.started).Milliseconds())}
|
||||
@@ -1879,7 +2179,11 @@ func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool
|
||||
summary := "完成"
|
||||
payload := core.AgentResultPayload{AgentRun: rc.run.ID, Status: status, Summary: summary}
|
||||
if execErr != nil {
|
||||
if rc.ctx.Err() != nil {
|
||||
if accountDenied != nil {
|
||||
status = core.RunFailed
|
||||
payload.Status = status
|
||||
payload.Summary = accountDenied.Error()
|
||||
} else if rc.ctx.Err() != nil {
|
||||
status = core.RunCancelled
|
||||
payload.Status = status
|
||||
payload.Summary = "已取消"
|
||||
@@ -1895,19 +2199,41 @@ func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool
|
||||
child.Status = status
|
||||
child.Phase = core.PhaseDone
|
||||
child.Usage = &usage
|
||||
m.st.UpdateRun(&child)
|
||||
if emitResult {
|
||||
if m.accountEffectiveAuthorization != nil && status == core.RunCompleted {
|
||||
if err := m.st.UpdateRunAccountChecked(string(child.Owner), &child, m.accountAllowImplicitLocal); err != nil {
|
||||
accountDenied = err
|
||||
status = core.RunFailed
|
||||
child.Status = status
|
||||
payload.Status = status
|
||||
payload.Summary = err.Error()
|
||||
_ = m.st.UpdateRun(&child)
|
||||
}
|
||||
} else {
|
||||
_ = m.st.UpdateRun(&child)
|
||||
}
|
||||
if emitResult && accountDenied == nil {
|
||||
m.emit(child.ConversationID, child.ID, core.EvAgentResult, payload)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
subParts := append([]core.Part{}, rc.h.assistant...)
|
||||
m.mu.Unlock()
|
||||
if accountDenied != nil {
|
||||
subParts = nil
|
||||
}
|
||||
// 独立转录落定(含终态 done 标记,供续跑幂等判定)。
|
||||
m.writeSubagentTranscript(child, subParts, string(status))
|
||||
transcriptCheck := func(checkCtx context.Context) error {
|
||||
return m.authorizeAccountEffective(checkCtx, child.Owner)
|
||||
}
|
||||
if m.accountEffectiveAuthorization == nil {
|
||||
transcriptCheck = nil
|
||||
}
|
||||
if err := m.writeSubagentTranscriptChecked(rc.ctx, child, subParts, string(status), transcriptCheck); err != nil {
|
||||
log.Printf("[runmgr] subagent transcript %s: %v", child.ID, err)
|
||||
}
|
||||
fullText := partsText(subParts)
|
||||
if len(subParts) > 0 {
|
||||
_ = m.st.AppendMessage(&core.Message{ConversationID: child.ConversationID, RunID: child.ID, Role: core.RoleAssistant, Parts: subParts})
|
||||
_ = m.appendMessageForEffectiveAccount(child.Owner, &core.Message{ConversationID: child.ConversationID, RunID: child.ID, Role: core.RoleAssistant, Parts: subParts})
|
||||
}
|
||||
if fullText != "" {
|
||||
payload.Text = capSubagentResult(rc, fullText)
|
||||
@@ -1944,6 +2270,9 @@ func (m *Manager) spawnBackground(parent core.Run, agentID, name, task, hint str
|
||||
}
|
||||
|
||||
func (m *Manager) spawnBackgroundOpts(parent core.Run, o bgSpawnOpts) (core.ID, error) {
|
||||
if err := m.authorizeRunWork(context.Background(), parent.Owner, parent.ProjectID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if parent.Kind == core.RunSubagent {
|
||||
return "", fmt.Errorf("子代理不能再派发子代理")
|
||||
}
|
||||
@@ -1990,6 +2319,10 @@ func (m *Manager) spawnBackgroundOpts(parent core.Run, o bgSpawnOpts) (core.ID,
|
||||
// startBackgroundSlot 持槽启动后台任务(调用方已占 bgInFlight)。
|
||||
// preCard 非空时复用排队卡(回填 SourceRunID 并转执行态),否则新建执行卡。
|
||||
func (m *Manager) startBackgroundSlot(parent core.Run, o bgSpawnOpts, preCard core.ActivityCard) (core.ID, error) {
|
||||
if err := m.authorizeRunWork(context.Background(), parent.Owner, parent.ProjectID); err != nil {
|
||||
m.decBackground(parent.ConversationID)
|
||||
return "", err
|
||||
}
|
||||
rc, runner, err := m.startChildOpts(context.Background(), parent, o, true)
|
||||
if err != nil {
|
||||
m.decBackground(parent.ConversationID)
|
||||
@@ -2013,7 +2346,7 @@ func (m *Manager) startBackgroundSlot(parent core.Run, o bgSpawnOpts, preCard co
|
||||
defer m.materialKeyRelease(string(parent.ConversationID), o.taskKey)
|
||||
}
|
||||
// 后台子代理同样计入执行预算(U3:子代理预算计入全局)。
|
||||
if err := m.adm.acquire(context.Background(), m.st, rc.run.ID, parent.Owner); err != nil {
|
||||
if err := m.adm.acquire(rc.ctx, m.st, rc.run.ID, parent.Owner, m.accountEffectiveAuthorization != nil, m.accountAllowImplicitLocal); err != nil {
|
||||
log.Printf("[runmgr] admission denied background %s: %v", rc.run.ID, err)
|
||||
m.setRunStatus(&rc.run, core.RunFailed, rc.run.Phase)
|
||||
m.st.UpdateRun(&core.Run{ID: rc.run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: err.Error()}})
|
||||
@@ -2068,7 +2401,7 @@ func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, t
|
||||
focus := "正在执行:" + title
|
||||
extra := append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"})
|
||||
body := (core.TaskSummaryView{Focus: focus}).RunningBody(statusLabel, extra)
|
||||
card, err := m.cards.Create(parent.Owner, core.ActivityCard{
|
||||
card, err := m.createCardForEffectiveAccount(parent.Owner, core.ActivityCard{
|
||||
Kind: core.CardKindProgress, Title: title, Status: core.CardActive, Tone: "info",
|
||||
ConversationID: parent.ConversationID, ProjectID: parent.ProjectID, SourceRunID: childID,
|
||||
Body: body,
|
||||
@@ -2096,7 +2429,7 @@ func (m *Manager) promoteQueuedCard(owner string, card core.ActivityCard, childI
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := m.cards.Update(owner, card); err != nil {
|
||||
if err := m.updateCardForEffectiveAccount(owner, card); err != nil {
|
||||
log.Printf("[runmgr] queued card promote: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -2125,10 +2458,10 @@ func (m *Manager) finishBackgroundCard(owner string, card core.ActivityCard, pay
|
||||
}
|
||||
extra := append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"})
|
||||
card.Body = (core.TaskSummaryView{OutcomeLine: summary}).FinishedBody(statusLabel, extra)
|
||||
if uerr := m.cards.Update(owner, card); uerr != nil {
|
||||
if uerr := m.updateCardForEffectiveAccount(owner, card); uerr != nil {
|
||||
log.Printf("[runmgr] background card update: %v", uerr)
|
||||
}
|
||||
if _, serr := m.cards.SetStatus(owner, card.ID, status, tone, ""); serr != nil {
|
||||
if _, serr := m.setCardStatusForEffectiveAccount(owner, card.ID, status, tone, ""); serr != nil {
|
||||
log.Printf("[runmgr] background card status: %v", serr)
|
||||
}
|
||||
}
|
||||
@@ -2148,14 +2481,22 @@ func (m *Manager) decBackground(convID string) {
|
||||
}
|
||||
|
||||
func (m *Manager) enqueueNotice(convID string, n bgNotice) {
|
||||
// 先落库(重启重投凭据),再进内存热队列;落库失败不丢内存路径。
|
||||
// 先落库(重启重投凭据),再进内存热队列;账号拒绝时不投递迟到结果。
|
||||
if n.id == "" {
|
||||
n.id = store.NewID("ntc")
|
||||
}
|
||||
if err := m.st.AppendNotice(store.Notice{
|
||||
notice := store.Notice{
|
||||
ID: n.id, ConversationID: convID, Owner: n.owner, Name: n.name, Payload: n.payload,
|
||||
}); err != nil {
|
||||
log.Printf("[runmgr] notice persist %s: %v", convID, err)
|
||||
}
|
||||
var persistErr error
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
persistErr = m.st.AppendNoticeAccountChecked(notice, m.accountAllowImplicitLocal)
|
||||
} else {
|
||||
persistErr = m.st.AppendNotice(notice)
|
||||
}
|
||||
if persistErr != nil {
|
||||
log.Printf("[runmgr] notice persist %s: %v", convID, persistErr)
|
||||
return
|
||||
}
|
||||
m.noticeMu.Lock()
|
||||
defer m.noticeMu.Unlock()
|
||||
@@ -2271,6 +2612,10 @@ func (m *Manager) startNoticeRun(convID string, items []bgNotice) error {
|
||||
m.releaseConversation(convID)
|
||||
return err
|
||||
}
|
||||
if err := m.authorizeRunWork(context.Background(), owner, conv.ProjectID); err != nil {
|
||||
m.releaseConversation(convID)
|
||||
return err
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<system-reminder>\n")
|
||||
for _, n := range items {
|
||||
@@ -2300,7 +2645,11 @@ func (m *Manager) startNoticeRun(convID string, items []bgNotice) error {
|
||||
ConversationID: convID, Role: core.RoleSystem,
|
||||
Parts: []core.Part{{Type: core.PartText, Text: b.String()}},
|
||||
}
|
||||
if err := m.st.AppendMessage(&msg); err != nil {
|
||||
if err := m.authorizeRunWork(context.Background(), owner, conv.ProjectID); err != nil {
|
||||
m.releaseConversation(convID)
|
||||
return err
|
||||
}
|
||||
if err := m.appendMessageForEffectiveAccount(owner, &msg); err != nil {
|
||||
m.releaseConversation(convID)
|
||||
return err
|
||||
}
|
||||
@@ -2342,7 +2691,13 @@ func (m *Manager) InterruptRunning() {
|
||||
parts = append(parts, h.assistant...)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
m.writeSubagentTranscript(run, parts, "interrupted")
|
||||
var check func(context.Context) error
|
||||
if m.accountEffectiveAuthorization != nil {
|
||||
check = func(ctx context.Context) error { return m.authorizeAccountEffective(ctx, run.Owner) }
|
||||
}
|
||||
if err := m.writeSubagentTranscriptChecked(context.Background(), run, parts, "interrupted", check); err != nil {
|
||||
log.Printf("[runmgr] interrupted transcript %s: %v", run.ID, err)
|
||||
}
|
||||
}
|
||||
m.sealRunInterrupted(id)
|
||||
}
|
||||
@@ -2387,7 +2742,7 @@ func (m *Manager) closePendingHITL(r core.Run) {
|
||||
}
|
||||
for _, c := range cards {
|
||||
res := core.HITLResolvedPayload{CardID: c.ID, Kind: "cancelled"}
|
||||
if err := m.st.ResolveHITLCard(r.Owner, string(c.ID), res); err != nil {
|
||||
if err := m.resolveHITLCardForEffectiveAccount(r.Owner, string(c.ID), res); err != nil {
|
||||
continue
|
||||
}
|
||||
m.emit(r.ConversationID, r.ID, core.EvHITLResolved, res)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -39,51 +40,94 @@ type subagentRequest struct {
|
||||
// writeSubagentRequest 在派生时落 request.json 与空转录(失败只记日志,
|
||||
// 不阻断派生——转录是辅助面)。
|
||||
func (m *Manager) writeSubagentRequest(run core.Run, agentID, name, task, hint string, detach bool) {
|
||||
if err := m.writeSubagentRequestChecked(context.Background(), run, agentID, name, task, hint, detach, nil); err != nil {
|
||||
log.Printf("[runmgr] subagent request %s: %v", run.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeSubagentRequestChecked keeps transcript file effects behind an optional
|
||||
// live account check. Failures stop later file writes; this auxiliary record
|
||||
// cannot be committed atomically with the account database.
|
||||
func (m *Manager) writeSubagentRequestChecked(ctx context.Context, run core.Run, agentID, name, task, hint string, detach bool, check func(context.Context) error) error {
|
||||
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
|
||||
if dir == "" {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if err := checkFileEffect(ctx, check); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
log.Printf("[runmgr] subagent dir %s: %v", run.ID, err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
body, _ := json.MarshalIndent(subagentRequest{
|
||||
AgentID: agentID, Name: name, Task: task, Hint: hint, Detach: detach,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}, "", " ")
|
||||
if err := checkFileEffect(ctx, check); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "request.json"), body, 0o644); err != nil {
|
||||
log.Printf("[runmgr] subagent request %s: %v", run.ID, err)
|
||||
return err
|
||||
}
|
||||
// 转录文件先建(append 语义清晰可见)。
|
||||
if _, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_CREATE|os.O_WRONLY, 0o644); err != nil {
|
||||
log.Printf("[runmgr] subagent transcript %s: %v", run.ID, err)
|
||||
if err := checkFileEffect(ctx, check); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
// writeSubagentTranscript 把本次收集到的 parts 追加进转录并落 done 标记。
|
||||
// status 是终态(completed/failed/cancelled/interrupted)。
|
||||
func (m *Manager) writeSubagentTranscript(run core.Run, parts []core.Part, status string) {
|
||||
if err := m.writeSubagentTranscriptChecked(context.Background(), run, parts, status, nil); err != nil {
|
||||
log.Printf("[runmgr] subagent transcript %s: %v", run.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) writeSubagentTranscriptChecked(ctx context.Context, run core.Run, parts []core.Part, status string, check func(context.Context) error) error {
|
||||
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
|
||||
if dir == "" {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if err := checkFileEffect(ctx, check); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
if err := checkFileEffect(ctx, check); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
log.Printf("[runmgr] transcript open %s: %v", run.ID, err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
enc := json.NewEncoder(f)
|
||||
for i, p := range parts {
|
||||
_ = enc.Encode(map[string]any{"index": i, "type": string(p.Type), "text": p.Text, "at": time.Now().UTC().Format(time.RFC3339)})
|
||||
if err := checkFileEffect(ctx, check); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
if err := enc.Encode(map[string]any{"index": i, "type": string(p.Type), "text": p.Text, "at": time.Now().UTC().Format(time.RFC3339)}); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = f.Close()
|
||||
done, _ := json.Marshal(map[string]string{"status": status, "endedAt": time.Now().UTC().Format(time.RFC3339)})
|
||||
if err := os.WriteFile(filepath.Join(dir, "done.json"), done, 0o644); err != nil {
|
||||
log.Printf("[runmgr] done marker %s: %v", run.ID, err)
|
||||
if err := checkFileEffect(ctx, check); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "done.json"), done, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// doneStatus 读 done.json 的 status(无文件或不可读按 "interrupted" 处理——
|
||||
@@ -122,12 +166,24 @@ func readSubagentRequest(dir string) (*subagentRequest, error) {
|
||||
// 未完成未续跑的,用原请求重新派生(后台),并把前次转录路径写进 hint
|
||||
// 供模型读取后继续(N2 WP5 的「可恢复」)。幂等:done/resumed 标记跳过。
|
||||
func (m *Manager) ResumeInterruptedChildren() {
|
||||
m.ResumeInterruptedChildrenChecked(context.Background(), nil)
|
||||
}
|
||||
|
||||
// ResumeInterruptedChildrenChecked rechecks each run owner before redispatch
|
||||
// and before writing the resumed marker. A disable between redispatch and the
|
||||
// marker can still leave a spawned run without this file; DB/file atomicity is
|
||||
// unavailable, so callers must keep the run admission guard as well.
|
||||
func (m *Manager) ResumeInterruptedChildrenChecked(ctx context.Context, check func(context.Context, string) error) {
|
||||
runs, err := m.st.ListInterruptedSubagentRuns()
|
||||
if err != nil {
|
||||
log.Printf("[runmgr] resume scan: %v", err)
|
||||
return
|
||||
}
|
||||
for _, run := range runs {
|
||||
if err := ctx.Err(); err != nil {
|
||||
log.Printf("[runmgr] resume cancelled: %v", err)
|
||||
return
|
||||
}
|
||||
// 恢复隔离(协作存储 U3):未知外部副作用的 run 不自动续跑——hold
|
||||
// 未解除即跳过;判定出错同样跳过(fail-closed:宁可人工核对,不盲重派)。
|
||||
if held, herr := m.st.HasOpenRecoveryHold(string(run.ID), string(run.ConversationID)); herr != nil || held {
|
||||
@@ -162,6 +218,10 @@ func (m *Manager) ResumeInterruptedChildren() {
|
||||
}
|
||||
hint := req.Hint + "\n(本任务是前次中断后的自动续跑;前次转录在 " +
|
||||
filepath.Join(dir, "transcript.jsonl") + ",可读取后继续。)"
|
||||
if err := checkOwnerFileEffect(ctx, run.Owner, check); err != nil {
|
||||
log.Printf("[runmgr] resume skip %s: account check: %v", run.ID, err)
|
||||
continue
|
||||
}
|
||||
newID, err := m.spawnBackground(parent, req.AgentID, req.Name, req.Task, hint)
|
||||
if err != nil {
|
||||
log.Printf("[runmgr] resume spawn %s: %v", run.ID, err)
|
||||
@@ -171,6 +231,10 @@ func (m *Manager) ResumeInterruptedChildren() {
|
||||
"newRun": newID,
|
||||
"resumedAt": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
if err := checkOwnerFileEffect(ctx, run.Owner, check); err != nil {
|
||||
log.Printf("[runmgr] resumed marker skip %s: account check: %v", run.ID, err)
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "resumed.json"), marker, 0o644); err != nil {
|
||||
log.Printf("[runmgr] resumed marker %s: %v", run.ID, err)
|
||||
}
|
||||
@@ -178,6 +242,13 @@ func (m *Manager) ResumeInterruptedChildren() {
|
||||
}
|
||||
}
|
||||
|
||||
func checkOwnerFileEffect(ctx context.Context, owner string, check func(context.Context, string) error) error {
|
||||
if check == nil {
|
||||
return checkFileEffect(ctx, nil)
|
||||
}
|
||||
return checkFileEffect(ctx, func(ctx context.Context) error { return check(ctx, owner) })
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func TestAdminAccountSearchFiltersAndVersionedCursor(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
admin := bootAdmin(t, h, st)
|
||||
alice, err := st.CreatePendingAccount("admin", "alice-search", store.AccountProfile{Name: "同名", Phone: "private-needle"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bob, err := st.CreatePendingAccount("admin", "bob-search", store.AccountProfile{Name: "同名", Phone: "other"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
search := func(query url.Values) (int, struct {
|
||||
Accounts []struct {
|
||||
UserID string `json:"userId"`
|
||||
Owner string `json:"owner"`
|
||||
} `json:"accounts"`
|
||||
NextCursor string `json:"nextCursor"`
|
||||
}) {
|
||||
response := do(h, "GET", "/api/admin/users/search?"+query.Encode(), "", admin)
|
||||
var page struct {
|
||||
Accounts []struct {
|
||||
UserID string `json:"userId"`
|
||||
Owner string `json:"owner"`
|
||||
} `json:"accounts"`
|
||||
NextCursor string `json:"nextCursor"`
|
||||
}
|
||||
_ = json.Unmarshal(response.Body.Bytes(), &page)
|
||||
return response.Code, page
|
||||
}
|
||||
params := url.Values{"query": {"同名"}, "mode": {"fuzzy"}, "fields": {"name"}, "limit": {"1"}}
|
||||
code, first := search(params)
|
||||
if code != http.StatusOK || len(first.Accounts) != 1 || first.Accounts[0].UserID != alice.ID || first.NextCursor == "" {
|
||||
t.Fatalf("first search page: %d %+v", code, first)
|
||||
}
|
||||
params.Set("cursor", first.NextCursor)
|
||||
other := New(st, srv.mgr, srv.reg, srv.prof, srv.set, srv.plugins)
|
||||
other.Auth = srv.Auth
|
||||
h = other.Handler()
|
||||
code, second := search(params)
|
||||
if code != http.StatusOK || len(second.Accounts) != 1 || second.Accounts[0].UserID != bob.ID || second.NextCursor != "" {
|
||||
t.Fatalf("second search page on another server: %d %+v", code, second)
|
||||
}
|
||||
params.Del("cursor")
|
||||
params.Set("query", "private-needle")
|
||||
params.Set("fields", "phone")
|
||||
params.Set("mode", "exact")
|
||||
code, contact := search(params)
|
||||
if code != http.StatusOK || len(contact.Accounts) != 1 || contact.Accounts[0].UserID != alice.ID {
|
||||
t.Fatalf("authorized contact search: %d %+v", code, contact)
|
||||
}
|
||||
params.Set("query", "同名")
|
||||
params.Set("fields", "name")
|
||||
params.Set("mode", "fuzzy")
|
||||
params.Set("cursor", first.NextCursor)
|
||||
if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion, map[string]string{"phone": "changed-private"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, _ = search(params)
|
||||
if code != http.StatusConflict {
|
||||
t.Fatalf("private profile edit did not expire system cursor: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfProfileRequiresPasswordAuthenticatedFullSession(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
hash, err := store.HashPassword("self-profile-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("legacy-self", "", hash, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.ClearMustChangePassword("legacy-self"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyCookie, err := st.CreateSession("legacy-self")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := do(h, "GET", "/api/me", "", legacyCookie); rec.Code != http.StatusOK {
|
||||
t.Fatalf("historical full session lost ordinary identity: %d", rec.Code)
|
||||
}
|
||||
if rec := do(h, "GET", "/api/me/profile", "", legacyCookie); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("unknown session lineage read profile: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := do(h, "PATCH", "/api/me/profile", `{"expectedProfileVersion":1,"patch":{"name":"injected"}}`, legacyCookie); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("unknown session lineage edited profile: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
login := do(h, "POST", "/api/auth/login", `{"owner":"legacy-self","password":"self-profile-password"}`, "")
|
||||
if login.Code != http.StatusOK {
|
||||
t.Fatalf("password login: %d %s", login.Code, login.Body.String())
|
||||
}
|
||||
if rec := do(h, "GET", "/api/me/profile", "", cookieOf(login)); rec.Code != http.StatusOK {
|
||||
t.Fatalf("verified password session could not read profile: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountHTTPPendingCreateProfileAndDisable(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
adminCookie := bootAdmin(t, h, st)
|
||||
for _, body := range []string{
|
||||
`{"owner":"blocked-password","password":"old-pass-9"}`,
|
||||
`{"owner":"blocked-admin","isAdmin":true}`,
|
||||
} {
|
||||
if rec := do(h, "POST", "/api/admin/users", body, adminCookie); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unsafe create accepted: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if _, err := st.GetUser("blocked-password"); err != store.ErrNotFound {
|
||||
t.Fatalf("unsafe create had side effect: %v", err)
|
||||
}
|
||||
rec := do(h, "POST", "/api/admin/users", `{"owner":"alice","profile":{"name":"同名"}}`, adminCookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("pending create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var created struct {
|
||||
UserID string `json:"userId"`
|
||||
Owner string `json:"owner"`
|
||||
ProfileVersion int64 `json:"profileVersion"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil || created.UserID == "" || created.Owner != "alice" || created.ProfileVersion != 1 {
|
||||
t.Fatalf("create projection: %+v %v", created, err)
|
||||
}
|
||||
rec = do(h, "PATCH", "/api/admin/users/"+created.UserID+"/profile", `{"expectedProfileVersion":1,"patch":{"phone":"123"}}`, adminCookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("profile patch: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = do(h, "PATCH", "/api/admin/users/"+created.UserID+"/profile", `{"expectedProfileVersion":2,"patch":{"isAdmin":"true"}}`, adminCookie)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("privilege field accepted: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = do(h, "PUT", "/api/admin/users/"+created.UserID+"/state", `{"expectedStateVersion":1,"enabled":false}`, adminCookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("disable: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
account, err := st.GetAccount(created.UserID)
|
||||
if err != nil || account.Enabled || account.Profile.Phone != "123" {
|
||||
t.Fatalf("state/profile after disable: %+v %v", account, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package server
|
||||
|
||||
// checkActorBeforeExternalEffect is the last effective-state check before a
|
||||
// process-local or filesystem effect. Such effects cannot share the account
|
||||
// database transaction, so callers must also use cancellation where available.
|
||||
func (s *Server) checkActorBeforeExternalEffect(actor string) error {
|
||||
return s.st.CheckEffectiveAccountForExternalEffect(actor, s.Auth == nil || !s.Auth.Enabled)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
type adminSearchCursor struct {
|
||||
KeyVersion int `json:"k"`
|
||||
Version string `json:"v"`
|
||||
Owner string `json:"o"`
|
||||
UserID string `json:"u"`
|
||||
}
|
||||
|
||||
func (s *Server) signAdminCursor(cursor adminSearchCursor) string {
|
||||
cursor.KeyVersion = s.accountSearchKey.Version
|
||||
data, _ := json.Marshal(cursor)
|
||||
mac := hmac.New(sha256.New, s.accountSearchKey.Secret[:])
|
||||
_, _ = mac.Write([]byte("iam-admin-search-cursor-v1\x00"))
|
||||
_, _ = mac.Write(data)
|
||||
return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (s *Server) parseAdminCursor(encoded, version string) (adminSearchCursor, bool) {
|
||||
var cursor adminSearchCursor
|
||||
payload, signature, found := strings.Cut(encoded, ".")
|
||||
if !found {
|
||||
return cursor, false
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(payload)
|
||||
if err != nil || len(data) > 1024 {
|
||||
return cursor, false
|
||||
}
|
||||
provided, err := base64.RawURLEncoding.DecodeString(signature)
|
||||
if err != nil {
|
||||
return cursor, false
|
||||
}
|
||||
mac := hmac.New(sha256.New, s.accountSearchKey.Secret[:])
|
||||
_, _ = mac.Write([]byte("iam-admin-search-cursor-v1\x00"))
|
||||
_, _ = mac.Write(data)
|
||||
if !hmac.Equal(provided, mac.Sum(nil)) || json.Unmarshal(data, &cursor) != nil || cursor.KeyVersion != s.accountSearchKey.Version || cursor.Version != version || cursor.Owner == "" || cursor.UserID == "" {
|
||||
return adminSearchCursor{}, false
|
||||
}
|
||||
return cursor, true
|
||||
}
|
||||
|
||||
func searchAccountField(account store.Account, field string) string {
|
||||
switch field {
|
||||
case "userId":
|
||||
return account.ID
|
||||
case "owner":
|
||||
return account.Owner
|
||||
case "name":
|
||||
return account.Profile.Name
|
||||
case "phone":
|
||||
return account.Profile.Phone
|
||||
case "email":
|
||||
return account.Profile.Email
|
||||
case "staffNo":
|
||||
return account.Profile.StaffNo
|
||||
case "department":
|
||||
return account.Profile.Department
|
||||
case "notes":
|
||||
return account.Profile.Notes
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) searchAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if s.accountSearchKeyErr != nil || s.accountSearchKey.Version < 1 {
|
||||
http.Error(w, "account directory signing unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
params := r.URL.Query()
|
||||
query := strings.TrimSpace(params.Get("query"))
|
||||
if !utf8.ValidString(query) || utf8.RuneCountInString(query) > 128 {
|
||||
http.Error(w, "invalid account search query", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for _, ch := range query {
|
||||
if unicode.IsControl(ch) {
|
||||
http.Error(w, "invalid account search query", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
mode := params.Get("mode")
|
||||
if mode == "" {
|
||||
mode = "fuzzy"
|
||||
}
|
||||
if mode != "exact" && mode != "fuzzy" {
|
||||
http.Error(w, "unknown account search mode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
status := params.Get("status")
|
||||
if status == "" {
|
||||
status = "all"
|
||||
}
|
||||
if status != "all" && status != "enabled" && status != "disabled" {
|
||||
http.Error(w, "unknown account status filter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
limit := 20
|
||||
if raw := params.Get("limit"); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed < 1 || parsed > 100 {
|
||||
http.Error(w, "invalid account search limit", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
fields := []string{"owner", "name", "userId"}
|
||||
if raw := params.Get("fields"); raw != "" {
|
||||
fields = strings.Split(raw, ",")
|
||||
}
|
||||
allowed := map[string]bool{"owner": true, "name": true, "userId": true, "phone": true, "email": true,
|
||||
"staffNo": true, "department": true, "notes": true}
|
||||
seen := map[string]bool{}
|
||||
for _, field := range fields {
|
||||
if !allowed[field] || seen[field] {
|
||||
http.Error(w, "unknown or duplicate account search field", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
seen[field] = true
|
||||
}
|
||||
sort.Strings(fields)
|
||||
accounts, revision, err := s.st.ListAdminAccountsForActor(actor)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
visibleRevision := sha256.Sum256([]byte(revision.Private))
|
||||
directoryVersion := hex.EncodeToString(visibleRevision[:])
|
||||
fingerprint, _ := json.Marshal(struct {
|
||||
Actor, Query, Mode, Status, Revision string
|
||||
Fields []string
|
||||
}{actor, query, mode, status, directoryVersion, fields})
|
||||
digest := sha256.Sum256(fingerprint)
|
||||
version := hex.EncodeToString(digest[:])
|
||||
var after adminSearchCursor
|
||||
if token := params.Get("cursor"); token != "" {
|
||||
var valid bool
|
||||
after, valid = s.parseAdminCursor(token, version)
|
||||
if !valid {
|
||||
http.Error(w, "account search cursor expired or invalid", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
page := make([]store.Account, 0, limit+1)
|
||||
for _, account := range accounts {
|
||||
if status == "enabled" && !account.Enabled || status == "disabled" && account.Enabled {
|
||||
continue
|
||||
}
|
||||
if after.Owner != "" && (account.Owner < after.Owner || account.Owner == after.Owner && account.ID <= after.UserID) {
|
||||
continue
|
||||
}
|
||||
if query != "" {
|
||||
matched := false
|
||||
for _, field := range fields {
|
||||
value := searchAccountField(account, field)
|
||||
if mode == "exact" && strings.EqualFold(value, query) || mode == "fuzzy" && strings.Contains(strings.ToLower(value), strings.ToLower(query)) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
}
|
||||
page = append(page, account)
|
||||
if len(page) > limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
more := len(page) > limit
|
||||
if more {
|
||||
page = page[:limit]
|
||||
}
|
||||
items := make([]map[string]any, 0, len(page))
|
||||
for _, account := range page {
|
||||
items = append(items, accountProfileJSON(account))
|
||||
}
|
||||
next := ""
|
||||
if more {
|
||||
last := page[len(page)-1]
|
||||
next = s.signAdminCursor(adminSearchCursor{Version: version, Owner: last.Owner, UserID: last.ID})
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"accounts": items, "nextCursor": next, "directoryVersion": directoryVersion})
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func TestAccountDisableStopsNextSSEBusinessFrame(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("sse-user", "", "user-hash", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.ClearMustChangePassword("sse-user"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, err := st.GetAccountByOwner("sse-user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, err := st.CreateProject("sse-user", "stream")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conv, err := st.CreateConversation("sse-user", project.ID, "stream", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
selected := make(chan core.StreamEvent, 1)
|
||||
srv.Subscribe = func(string) (<-chan core.StreamEvent, func()) { return selected, func() {} }
|
||||
token, err := st.CreateSession("sse-user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
httpServer := httptest.NewServer(srv.Handler())
|
||||
defer httpServer.Close()
|
||||
request, err := http.NewRequest(http.MethodGet, httpServer.URL+"/api/events?conversationId="+conv.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.AddCookie(&http.Cookie{Name: SessionCookie, Value: token})
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
reader := bufio.NewReader(response.Body)
|
||||
first, err := reader.ReadString('\n')
|
||||
if err != nil || !strings.HasPrefix(first, "retry:") {
|
||||
t.Fatalf("SSE did not open: %q %v", first, err)
|
||||
}
|
||||
if _, err := st.SetAccountEnabled("admin", user.ID, user.StateVersion, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
selected <- core.StreamEvent{ConversationID: conv.ID, Seq: 1, Type: core.EvToolOutput,
|
||||
Payload: json.RawMessage(`{"text":"must-not-stream"}`)}
|
||||
tail, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("revoked stream did not close: %v", err)
|
||||
}
|
||||
if strings.Contains(string(tail), "must-not-stream") {
|
||||
t.Fatal("disabled account received the next SSE business frame")
|
||||
}
|
||||
if _, _, ok := st.SessionInfo(token); ok {
|
||||
t.Fatal("disabled account session still authenticates")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
type pausedRequestBody struct {
|
||||
reader io.Reader
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (b *pausedRequestBody) Read(p []byte) (int, error) {
|
||||
b.once.Do(func() { close(b.entered) })
|
||||
<-b.release
|
||||
return b.reader.Read(p)
|
||||
}
|
||||
|
||||
func (*pausedRequestBody) Close() error { return nil }
|
||||
|
||||
func TestProjectCreateCannotCommitAfterAccountDisable(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
_ = bootAdmin(t, h, st)
|
||||
hash, err := store.HashPassword("actor-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("actor", "Actor", hash, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.ClearMustChangePassword("actor"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, err := st.CreateAPIKey("actor", "request")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account, err := st.GetAccountByOwner("actor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := &pausedRequestBody{reader: strings.NewReader(`{"name":"late-project"}`), entered: make(chan struct{}), release: make(chan struct{})}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/projects", body)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec := httptest.NewRecorder()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
h.ServeHTTP(rec, req)
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-body.entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("project request never reached its body")
|
||||
}
|
||||
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(body.release)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("request did not finish after disable")
|
||||
}
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("disabled request committed: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var n int
|
||||
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM projects WHERE owner='actor'`).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatalf("late project row count=%d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserMessageCannotCommitAfterAccountDisable(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
_ = bootAdmin(t, h, st)
|
||||
key, err := createLegacyAPIKeyFixture(t, st, "actor", "request")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account, err := st.GetAccountByOwner("actor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, err := st.CreateProject("actor", "fixture")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversation, err := st.CreateConversation("actor", string(project.ID), "fixture", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := &pausedRequestBody{reader: strings.NewReader(`{"text":"late message"}`), entered: make(chan struct{}), release: make(chan struct{})}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/conversations/"+string(conversation.ID)+"/messages", body)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec := httptest.NewRecorder()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
h.ServeHTTP(rec, req)
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-body.entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("message request never reached its body")
|
||||
}
|
||||
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(body.release)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("message request did not finish after disable")
|
||||
}
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("disabled message request committed: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var n int
|
||||
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM messages WHERE conversation_id=?`, conversation.ID).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatalf("late message count=%d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCannotDownloadOtherOwnersThroughDatabaseBackup(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
admin := bootAdmin(t, h, st)
|
||||
rec := do(h, http.MethodPost, "/api/admin/backup", "{}", admin)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("system admin received all-owner database backup: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegenerationCannotChangeMessageAfterAccountDisable(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
_ = bootAdmin(t, h, st)
|
||||
key, err := createLegacyAPIKeyFixture(t, st, "actor", "regenerate")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account, err := st.GetAccountByOwner("actor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, err := st.CreateProject("actor", "fixture")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conv, err := st.CreateConversation("actor", string(project.ID), "fixture", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := core.Message{ConversationID: conv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "question"}}}
|
||||
target := core.Message{ConversationID: conv.ID, Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartText, Text: "answer"}}}
|
||||
if err := st.AppendMessage(&user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.AppendMessage(&target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := &pausedRequestBody{reader: strings.NewReader(`{"messageId":"` + target.ID + `"}`), entered: make(chan struct{}), release: make(chan struct{})}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/conversations/"+string(conv.ID)+"/regenerate", body)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec := httptest.NewRecorder()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
h.ServeHTTP(rec, req)
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-body.entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("regeneration request never reached its body")
|
||||
}
|
||||
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(body.release)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("regeneration did not finish after disable")
|
||||
}
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("disabled regeneration status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
got, err := st.MessageByID(target.ID)
|
||||
if err != nil || got.Excluded || got.VariantGroup != "" {
|
||||
t.Fatalf("rejected regeneration changed target: %+v %v", got, err)
|
||||
}
|
||||
if _, _, ok := st.PeekRegenPending(string(conv.ID)); ok {
|
||||
t.Fatal("rejected regeneration left marker")
|
||||
}
|
||||
}
|
||||
+173
-89
@@ -24,6 +24,19 @@ func decodeJSON(r *http.Request, v any) error {
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
|
||||
func decodeStrictJSON(r *http.Request, v any) error {
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(v); err != nil {
|
||||
return err
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
return errors.New("exactly one JSON object required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 登录失败退避(进程内;管理员发放制下用户量小,进程内即可,实例重启清零)。
|
||||
var loginGuard = struct {
|
||||
sync.Mutex
|
||||
@@ -91,6 +104,7 @@ func authStateJSON(st store.UserAuthState) map[string]any {
|
||||
// redeemLoginKey:一次性登录 Key → set_password_only 会话(专用 body,不经 Bearer)。
|
||||
// 请求只有 Key:身份由服务端绑定记录确定;按来源 IP 退避防爆破。
|
||||
func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if s.Auth == nil || !s.Auth.Enabled {
|
||||
http.Error(w, "auth disabled (embedded single-tenant mode)", http.StatusPreconditionFailed)
|
||||
return
|
||||
@@ -124,6 +138,7 @@ func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) {
|
||||
st, _ := s.st.GetUserAuthState(owner)
|
||||
out := authStateJSON(st)
|
||||
out["sessionScope"] = store.SessionScopeSetPasswordOnly
|
||||
out["passwordChangeMode"] = store.PasswordChangeModeGrantSetup
|
||||
out["reloginRequired"] = false
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -179,6 +194,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
setSessionCookie(w, r, token, maxAge)
|
||||
out := authStateJSON(st)
|
||||
out["sessionScope"] = scope
|
||||
out["passwordChangeMode"] = store.PasswordChangeModeCurrentRequired
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
@@ -206,6 +222,11 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "session expired", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
mode, err := s.st.SessionPasswordChangeMode(tok)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Current string `json:"current"`
|
||||
Next string `json:"next"`
|
||||
@@ -223,15 +244,27 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
st0, _ := s.st.GetUserAuthState(owner)
|
||||
forceSet := scope == store.SessionScopeSetPasswordOnly || st0.MustChangePassword || !st0.HasPassword
|
||||
if forceSet {
|
||||
if _, err := s.st.CompleteSetPasswordChecked(tok, hash); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
if scope == store.SessionScopeSetPasswordOnly {
|
||||
var completeErr error
|
||||
switch mode {
|
||||
case store.PasswordChangeModeGrantSetup:
|
||||
_, completeErr = s.st.CompleteSetPasswordChecked(tok, hash)
|
||||
case store.PasswordChangeModeCurrentRequired:
|
||||
_, completeErr = s.st.CompleteForcedPasswordChecked(tok, req.Current, hash)
|
||||
default:
|
||||
http.Error(w, "unsupported password change mode", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if completeErr != nil {
|
||||
if errors.Is(completeErr, store.ErrBadCredentials) {
|
||||
http.Error(w, "current password incorrect", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if errors.Is(completeErr, store.ErrNotFound) {
|
||||
http.Error(w, "session expired", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
writeStoreErr(w, err)
|
||||
writeStoreErr(w, completeErr)
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, r, "", -1)
|
||||
@@ -270,7 +303,7 @@ func validPassword(pw string) error {
|
||||
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
owner := s.owner(r)
|
||||
st, err := s.st.GetUserAuthState(owner)
|
||||
if err != nil || !st.IsAdmin {
|
||||
if err != nil || !st.Enabled || !st.IsAdmin || s.Auth == nil || !s.Auth.Enabled {
|
||||
http.Error(w, "admin required", http.StatusForbidden)
|
||||
return "", false
|
||||
}
|
||||
@@ -286,16 +319,15 @@ func (s *Server) listAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAdmin(w, r); !ok {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Password string `json:"password"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Owner string `json:"owner"`
|
||||
Profile store.AccountProfile `json:"profile"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
if err := decodeStrictJSON(r, &req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -304,59 +336,139 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "owner required (no spaces or slashes)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validPassword(req.Password); err != nil {
|
||||
http.Error(w, "initial password must be 8+ characters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
hash, err := store.HashPassword(req.Password)
|
||||
account, err := s.st.CreatePendingAccount(actor, req.Owner, req.Profile)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.st.CreateAccount(req.Owner, strings.TrimSpace(req.Name), hash, req.IsAdmin); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
st, _ := s.st.GetUserAuthState(req.Owner)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"owner": st.Owner, "name": st.Name, "isAdmin": st.IsAdmin,
|
||||
"mustChangePassword": st.MustChangePassword,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, accountProfileJSON(account))
|
||||
}
|
||||
|
||||
func accountProfileJSON(account store.Account) map[string]any {
|
||||
return map[string]any{
|
||||
"userId": account.ID, "owner": account.Owner, "profile": account.Profile,
|
||||
"profileVersion": account.ProfileVersion, "enabled": account.Enabled,
|
||||
"stateVersion": account.StateVersion, "isAdmin": account.IsAdmin,
|
||||
"passwordSet": account.HasPassword, "initializationState": account.InitializationState,
|
||||
"initializationGeneration": account.InitializationGeneration,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) requireFullPasswordSession(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
if s.Auth == nil || !s.Auth.Enabled || AuthVia(r) != AuthViaSession || SessionScope(r) != store.SessionScopeFull {
|
||||
http.Error(w, "full password session required", http.StatusForbidden)
|
||||
return "", false
|
||||
}
|
||||
token := sessionToken(r)
|
||||
owner, scope, valid := s.st.SessionInfo(token)
|
||||
if !valid || scope != store.SessionScopeFull || owner != s.owner(r) {
|
||||
http.Error(w, "full password session required", http.StatusForbidden)
|
||||
return "", false
|
||||
}
|
||||
st, err := s.st.GetUserAuthState(owner)
|
||||
if err != nil || !st.Enabled || st.MustChangePassword || !st.HasPassword {
|
||||
http.Error(w, "full password session required", http.StatusForbidden)
|
||||
return "", false
|
||||
}
|
||||
method, err := s.st.SessionAuthMethod(token)
|
||||
if err != nil || method != store.AuthMethodPassword {
|
||||
http.Error(w, "password-authenticated session required", http.StatusForbidden)
|
||||
return "", false
|
||||
}
|
||||
return owner, true
|
||||
}
|
||||
|
||||
func (s *Server) meProfile(w http.ResponseWriter, r *http.Request) {
|
||||
owner, ok := s.requireFullPasswordSession(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
account, err := s.st.GetAccountByOwner(owner)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, accountProfileJSON(account))
|
||||
}
|
||||
|
||||
func (s *Server) patchMeProfile(w http.ResponseWriter, r *http.Request) {
|
||||
owner, ok := s.requireFullPasswordSession(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
account, err := s.st.GetAccountByOwner(owner)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.patchAccountProfile(w, r, owner, account.ID)
|
||||
}
|
||||
|
||||
func (s *Server) adminAccount(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
account, err := s.st.GetAccount(r.PathValue("userId"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, accountProfileJSON(account))
|
||||
}
|
||||
|
||||
func (s *Server) patchAdminAccountProfile(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.patchAccountProfile(w, r, actor, r.PathValue("userId"))
|
||||
}
|
||||
|
||||
func (s *Server) patchAccountProfile(w http.ResponseWriter, r *http.Request, actor, userID string) {
|
||||
var req struct {
|
||||
ExpectedProfileVersion int64 `json:"expectedProfileVersion"`
|
||||
Patch map[string]string `json:"patch"`
|
||||
}
|
||||
if err := decodeStrictJSON(r, &req); err != nil || req.Patch == nil {
|
||||
http.Error(w, "invalid profile patch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
account, err := s.st.PatchAccountProfile(actor, userID, req.ExpectedProfileVersion, req.Patch)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, accountProfileJSON(account))
|
||||
}
|
||||
|
||||
func (s *Server) setAdminAccountState(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ExpectedStateVersion int64 `json:"expectedStateVersion"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := decodeStrictJSON(r, &req); err != nil || req.Enabled == nil {
|
||||
http.Error(w, "invalid state change", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
account, err := s.st.SetAccountEnabled(actor, r.PathValue("userId"), req.ExpectedStateVersion, *req.Enabled)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, accountProfileJSON(account))
|
||||
}
|
||||
|
||||
// resetAdminUserPassword:管理员直接重置密码(兼容保留的既有入口;常规恢复
|
||||
// 走签发一次性登录 Key)。单事务:must_change=1、bump auth_version、作废未用
|
||||
// 登录 Key、吊销全部会话、审计。
|
||||
func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
if _, ok := s.requireAdmin(w, r); ok {
|
||||
http.Error(w, "RECOVERY_CONTRACT_REPLACED: propose recovery instead", http.StatusGone)
|
||||
}
|
||||
owner := r.PathValue("owner")
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validPassword(req.Password); err != nil {
|
||||
http.Error(w, "new password must be 8+ characters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
hash, err := store.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.st.AdminResetUserPassword(owner, hash, actor, "admin-http"); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
st, _ := s.st.GetUserAuthState(owner)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"owner": st.Owner, "mustChangePassword": st.MustChangePassword,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 管理员:一次性登录 Key(恢复流程主入口)----
|
||||
@@ -364,38 +476,9 @@ func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request)
|
||||
// issueAdminLoginKey:为指定既有用户签发恢复 Key(明文仅本次响应返回)。
|
||||
// 同事务撤销旧恢复 Key 及其受限会话;不改密码、不吊销正常会话、不动业务数据。
|
||||
func (s *Server) issueAdminLoginKey(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
if _, ok := s.requireAdmin(w, r); ok {
|
||||
http.Error(w, "RECOVERY_CONTRACT_REPLACED: propose recovery instead", http.StatusGone)
|
||||
}
|
||||
owner := strings.TrimSpace(r.PathValue("owner"))
|
||||
var req struct {
|
||||
Purpose string `json:"purpose"`
|
||||
TTLMinutes int `json:"ttlMinutes"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil && !errors.Is(err, io.EOF) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
purpose := req.Purpose
|
||||
if purpose == "" {
|
||||
purpose = store.LoginKeyPurposeRecover
|
||||
}
|
||||
if purpose != store.LoginKeyPurposeRecover && purpose != store.LoginKeyPurposeActivate {
|
||||
http.Error(w, "purpose must be recover or activate", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ttl := time.Duration(req.TTLMinutes) * time.Minute
|
||||
plain, meta, err := s.st.IssueLoginKey(owner, purpose, actor, ttl)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": meta.ID, "owner": meta.Owner, "purpose": meta.Purpose,
|
||||
"status": meta.Status, "expiresAt": meta.ExpiresAt, "createdBy": meta.CreatedBy,
|
||||
"key": plain, // 明文仅此一次;关闭弹层后不可再取
|
||||
})
|
||||
}
|
||||
|
||||
// listAdminLoginKeys:指定用户的 Key 状态与有效期(不返回明文)。
|
||||
@@ -413,7 +496,7 @@ func (s *Server) revokeAdminLoginKey(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := s.st.RevokeLoginKeyCascade(r.PathValue("id"), actor); err != nil {
|
||||
if err := s.st.RevokeLoginKeyCascadeAdmin(actor, r.PathValue("owner"), r.PathValue("id")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -441,7 +524,8 @@ func (s *Server) listRecoveryHolds(w http.ResponseWriter, r *http.Request) {
|
||||
// releaseRecoveryHold 人工解除单条 hold(幂等);解除后该 run 才可能被再次
|
||||
// 续跑/接管。副作用未核对就解除的风险由操作者承担——与 task-close 同级谨慎。
|
||||
func (s *Server) releaseRecoveryHold(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAdmin(w, r); !ok {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
runID := r.PathValue("runId")
|
||||
@@ -449,5 +533,5 @@ func (s *Server) releaseRecoveryHold(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeOKOr(w, s.st.ReleaseRecoveryHold(runID))
|
||||
writeOKOr(w, s.st.ReleaseRecoveryHoldAccountChecked(actor, runID, s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestListAdminZoneSections(t *testing.T) {
|
||||
}},
|
||||
}
|
||||
h := srv.Handler()
|
||||
key, err := st.CreateAPIKey("alice", "e2e")
|
||||
key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// createLegacyAPIKeyFixture creates the historical integration identity
|
||||
// explicitly before exercising the formal, non-creating key issuance path.
|
||||
func createLegacyAPIKeyFixture(t *testing.T, st *store.Store, owner, name string) (string, error) {
|
||||
t.Helper()
|
||||
if err := st.EnsureUser(owner, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st.CreateAPIKey(owner, name)
|
||||
}
|
||||
@@ -17,11 +17,11 @@ func TestCommandAuditOwnerScoping(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
|
||||
aliceKey, err := st.CreateAPIKey("alice", "e2e")
|
||||
aliceKey, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bobKey, err := st.CreateAPIKey("bob", "e2e")
|
||||
bobKey, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler {
|
||||
}
|
||||
p := r.URL.Path
|
||||
// 豁免:静态前端、登录、一次性 Key 兑换(body 专用,不走 Bearer)。
|
||||
if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" || p == "/api/auth/login-key/redeem" {
|
||||
if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" || p == "/api/auth/login-key/redeem" || p == "/api/auth/recovery-grants/redeem" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -54,6 +54,10 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler {
|
||||
if key == "" {
|
||||
key = r.URL.Query().Get("key")
|
||||
}
|
||||
if key != "" && sessionToken(r) != "" {
|
||||
http.Error(w, "conflicting credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// 一次性登录 Key 不得经 Bearer / ?key= 使用。
|
||||
if strings.HasPrefix(key, "alk_") {
|
||||
http.Error(w, "unauthorized: one-time login keys must use /api/auth/login-key/redeem", http.StatusUnauthorized)
|
||||
@@ -66,8 +70,6 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler {
|
||||
if key != "" {
|
||||
if o, keyOK := a.st.AuthOwner(key); keyOK {
|
||||
owner, via, scope, ok = o, AuthViaKey, store.SessionScopeFull, true
|
||||
} else if o, sc, sessOK := cookieSession(r, a.st); sessOK {
|
||||
owner, via, scope, ok = o, AuthViaSession, sc, true
|
||||
} else {
|
||||
http.Error(w, "unauthorized: invalid or revoked API key", http.StatusUnauthorized)
|
||||
return
|
||||
@@ -99,8 +101,9 @@ func (a *Authenticator) enforcePasswordGate(w http.ResponseWriter, r *http.Reque
|
||||
return nil
|
||||
}
|
||||
st, err := a.st.GetUserAuthState(owner)
|
||||
if err != nil {
|
||||
return nil
|
||||
if err != nil || !st.Enabled {
|
||||
http.Error(w, "unauthorized: account unavailable", http.StatusUnauthorized)
|
||||
return errGate
|
||||
}
|
||||
// 长期 API Key:仅当 must_change(已有密码待改)时拒绝业务,防止绕过改密门;
|
||||
// 无密码的纯集成账户仍可走 Key(与历史 apikey 语义兼容)。
|
||||
|
||||
@@ -36,7 +36,11 @@ func newAuthTestServer(t *testing.T) (*Server, *store.Store) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return newAuthTestServerOnStore(t, st, dir), st
|
||||
}
|
||||
|
||||
func newAuthTestServerOnStore(t *testing.T, st *store.Store, dir string) *Server {
|
||||
t.Helper()
|
||||
b := bus.New()
|
||||
reg := tools.NewRegistry()
|
||||
reg.RegisterBuiltin()
|
||||
@@ -48,7 +52,7 @@ func newAuthTestServer(t *testing.T) (*Server, *store.Store) {
|
||||
srv := New(st, mgr, reg, profSvc, setSvc, ph)
|
||||
srv.Auth = NewAuthenticator(st, true)
|
||||
srv.Subscribe = func(cid string) (<-chan core.StreamEvent, func()) { return b.Subscribe(cid) }
|
||||
return srv, st
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestAuthMiddleware(t *testing.T) {
|
||||
@@ -64,7 +68,7 @@ func TestAuthMiddleware(t *testing.T) {
|
||||
}
|
||||
|
||||
// 创建密钥。
|
||||
key, err := st.CreateAPIKey("alice", "e2e")
|
||||
key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -103,8 +107,8 @@ func TestAuthMiddleware(t *testing.T) {
|
||||
func TestTenantIsolationHTTP(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
keyA, _ := st.CreateAPIKey("alice", "a")
|
||||
keyB, _ := st.CreateAPIKey("bob", "b")
|
||||
keyA, _ := createLegacyAPIKeyFixture(t, st, "alice", "a")
|
||||
keyB, _ := createLegacyAPIKeyFixture(t, st, "bob", "b")
|
||||
|
||||
do := func(key, method, path string, body any) int {
|
||||
var buf bytes.Buffer
|
||||
@@ -328,8 +332,8 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
if rec := session("POST", "/api/admin/users", `{"owner":"alice","name":"爱丽丝","password":"alice-init-01"}`); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("must_change admin must be gated before create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// 首登改密:不要求 current;成功后销毁会话,须重新密码登录。
|
||||
if rec := session("POST", "/api/auth/password", `{"next":"admin-final-9"}`); rec.Code != http.StatusOK ||
|
||||
// 历史初始密码必须提供 current;成功后销毁会话,须重新密码登录。
|
||||
if rec := session("POST", "/api/auth/password", `{"current":"boot-pass-00","next":"admin-final-9"}`); rec.Code != http.StatusOK ||
|
||||
!strings.Contains(rec.Body.String(), `"reloginRequired":true`) {
|
||||
t.Fatalf("admin force set-password: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -345,14 +349,21 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
}
|
||||
tok = strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0]
|
||||
|
||||
// 管理员建号(普通用户,初始密码)。
|
||||
if rec := session("POST", "/api/admin/users", `{"owner":"alice","name":"爱丽丝","password":"alice-init-01"}`); rec.Code != http.StatusOK {
|
||||
// 管理员只建无凭据的普通账号;历史强制改密夹具由 Store 直接设置。
|
||||
if rec := session("POST", "/api/admin/users", `{"owner":"alice","profile":{"name":"爱丽丝"}}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("admin create user: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// 重名冲突 409。
|
||||
if rec := session("POST", "/api/admin/users", `{"owner":"alice","password":"alice-init-01"}`); rec.Code != http.StatusConflict {
|
||||
if rec := session("POST", "/api/admin/users", `{"owner":"alice"}`); rec.Code != http.StatusConflict {
|
||||
t.Fatalf("duplicate owner must 409: %d", rec.Code)
|
||||
}
|
||||
legacyHash, err := store.HashPassword("alice-init-01")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetUserPassword("alice", legacyHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// alice 首登:mustChangePassword=true;业务 API 被设密门拒绝。
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-init-01"}`))
|
||||
@@ -380,11 +391,11 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
if rec := alice("POST", "/api/admin/users", `{"owner":"bob","password":"bob-init-001"}`); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("must_change non-admin create must 403: %d", rec.Code)
|
||||
}
|
||||
// must_change 改密忽略 current;短密码拒绝;成功后 reloginRequired。
|
||||
// must_change 改密要求 current;短密码拒绝;成功后 reloginRequired。
|
||||
if rec := alice("POST", "/api/auth/password", `{"next":"short"}`); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("short password must 400: %d", rec.Code)
|
||||
}
|
||||
if rec := alice("POST", "/api/auth/password", `{"current":"ignored","next":"alice-final-9"}`); rec.Code != http.StatusOK ||
|
||||
if rec := alice("POST", "/api/auth/password", `{"current":"alice-init-01","next":"alice-final-9"}`); rec.Code != http.StatusOK ||
|
||||
!strings.Contains(rec.Body.String(), `"reloginRequired":true`) {
|
||||
t.Fatalf("alice force set-password: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -412,23 +423,23 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
t.Fatalf("non-admin create must 403: %d", rec.Code)
|
||||
}
|
||||
|
||||
// 管理员重置 alice 密码 → 其旧会话吊销 + 首登再改密。
|
||||
if rec := session("POST", "/api/admin/users/alice/password", `{"password":"reset-init-02"}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("admin reset: %d %s", rec.Code, rec.Body.String())
|
||||
// 旧管理员重置入口关闭,不能动 alice 的密码与当前会话。
|
||||
if rec := session("POST", "/api/admin/users/alice/password", `{"password":"reset-init-02"}`); rec.Code != http.StatusGone {
|
||||
t.Fatalf("admin reset must be closed: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("alice old session must be revoked after reset: %d", rec.Code)
|
||||
if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("closed reset must preserve alice session: %d", rec.Code)
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"reset-init-02"}`))
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-final-9"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if !strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
|
||||
t.Fatalf("reset must force change on next login: %s", rec.Body.String())
|
||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
|
||||
t.Fatalf("closed reset must preserve normal login: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// API key 通道兼容:密钥登录照旧(并具备其 owner 身份)。
|
||||
key, err := st.CreateAPIKey("admin", "cli")
|
||||
key, err := createLegacyAPIKeyFixture(t, st, "admin", "cli")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -447,7 +458,7 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"reset-init-02"}`))
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-final-9"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
@@ -464,8 +475,8 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaleKeySessionFallback:浏览器残留失效 Bearer key 时,有效会话不被一票否决;
|
||||
// /api/me 的 authVia 区分凭证通道(key / session),供前端区分改密门放行范围。
|
||||
// TestStaleKeySessionFallback:混合 Bearer 与 cookie 的认证上下文不确定,必须拒绝。
|
||||
// /api/me 的 authVia 仍区分单独有效的 key / session。
|
||||
func TestStaleKeySessionFallback(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
@@ -486,14 +497,14 @@ func TestStaleKeySessionFallback(t *testing.T) {
|
||||
}
|
||||
tok := strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0]
|
||||
|
||||
// 会话 + 失效 key:回落会话(200,authVia=session),不再被 key 一票否决。
|
||||
// 会话 + 失效 key:拒绝混合凭证,不把有效 cookie 当证明兜底。
|
||||
req = httptest.NewRequest("GET", "/api/me", nil)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok})
|
||||
req.Header.Set("Authorization", "Bearer ask_stale_key")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"authVia":"session"`) {
|
||||
t.Fatalf("stale key + valid session must fall back to session: %d %s", rec.Code, rec.Body.String())
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("stale key + valid session must be rejected: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 失效 key 且无会话:仍 401(key 专属文案)。
|
||||
@@ -506,7 +517,7 @@ func TestStaleKeySessionFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
// 有效 key:authVia=key。
|
||||
key, err := st.CreateAPIKey("carol", "cli")
|
||||
key, err := createLegacyAPIKeyFixture(t, st, "carol", "cli")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -544,11 +555,11 @@ func TestSystemSettingsAdminGate(t *testing.T) {
|
||||
if err := st.CreateAccount("dave", "普通乙", hash, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyBoss, err := st.CreateAPIKey("boss", "cli")
|
||||
keyBoss, err := createLegacyAPIKeyFixture(t, st, "boss", "cli")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyDave, err := st.CreateAPIKey("dave", "cli")
|
||||
keyDave, err := createLegacyAPIKeyFixture(t, st, "dave", "cli")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+55
-42
@@ -28,16 +28,20 @@ func (s *Server) createSecret(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
sec, err := s.st.AddSecret(s.owner(r), req.Service, req.Label, req.Value)
|
||||
if strings.TrimSpace(req.Service) == "" || strings.TrimSpace(req.Value) == "" {
|
||||
http.Error(w, "store: secret service and value are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
sec, err := s.st.AddSecretAccountChecked(s.owner(r), req.Service, req.Label, req.Value, s.Auth == nil || !s.Auth.Enabled)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sec)
|
||||
}
|
||||
|
||||
func (s *Server) activateSecret(w http.ResponseWriter, r *http.Request) {
|
||||
writeOKOr(w, s.st.ActivateSecret(s.owner(r), r.PathValue("id")))
|
||||
writeOKOr(w, s.st.ActivateSecretAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
func (s *Server) renameSecret(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -48,11 +52,11 @@ func (s *Server) renameSecret(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeOKOr(w, s.st.RenameSecret(s.owner(r), r.PathValue("id"), req.Label))
|
||||
writeOKOr(w, s.st.RenameSecretAccountChecked(s.owner(r), r.PathValue("id"), req.Label, s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request) {
|
||||
writeOKOr(w, s.st.DeleteSecret(s.owner(r), r.PathValue("id")))
|
||||
writeOKOr(w, s.st.DeleteSecretAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
func (s *Server) listContextEntries(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -67,16 +71,20 @@ func (s *Server) saveContextEntry(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
saved, err := s.st.SaveContextEntry(s.owner(r), e)
|
||||
if strings.TrimSpace(e.Content) == "" {
|
||||
http.Error(w, "store: entry content required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
saved, err := s.st.SaveContextEntryAccountChecked(s.owner(r), e, s.Auth == nil || !s.Auth.Enabled)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, saved)
|
||||
}
|
||||
|
||||
func (s *Server) deleteContextEntry(w http.ResponseWriter, r *http.Request) {
|
||||
writeOKOr(w, s.st.DeleteContextEntry(s.owner(r), r.PathValue("id")))
|
||||
writeOKOr(w, s.st.DeleteContextEntryAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
func (s *Server) listUserPromptCommands(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -90,31 +98,46 @@ func (s *Server) saveUserPromptCommand(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
saved, err := s.st.SaveUserCommand(s.owner(r), c)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
if strings.TrimSpace(c.Template) == "" {
|
||||
http.Error(w, "store: command template required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.registerUserPrompt(saved)
|
||||
owner := s.owner(r)
|
||||
allowImplicitDefault := s.Auth == nil || !s.Auth.Enabled
|
||||
saved, err := s.st.SaveUserCommandAccountChecked(owner, c, allowImplicitDefault)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if s.Bus != nil {
|
||||
// The Bus is outside the database transaction. Recheck immediately before
|
||||
// mutation; a disable between this check and RegisterUser remains possible.
|
||||
if err := s.st.CheckEffectiveAccountForExternalEffect(owner, allowImplicitDefault); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.registerUserPrompt(saved)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, saved)
|
||||
}
|
||||
|
||||
func (s *Server) deleteUserPromptCommand(w http.ResponseWriter, r *http.Request) {
|
||||
owner := s.owner(r)
|
||||
id := r.PathValue("id")
|
||||
list, _ := s.st.ListUserCommands(owner)
|
||||
var name string
|
||||
for _, c := range list {
|
||||
if c.ID == id {
|
||||
name = c.Name
|
||||
}
|
||||
}
|
||||
if err := s.st.DeleteUserCommand(owner, id); err != nil {
|
||||
allowImplicitDefault := s.Auth == nil || !s.Auth.Enabled
|
||||
deleted, err := s.st.DeleteUserCommandAccountChecked(owner, id, allowImplicitDefault)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if s.Bus != nil && name != "" {
|
||||
s.Bus.UnregisterUser(userCommandID(name), name)
|
||||
if s.Bus != nil && deleted.Name != "" {
|
||||
// The account check is the last database action before the Bus mutation;
|
||||
// the two systems cannot share an atomic commit.
|
||||
if err := s.st.CheckEffectiveAccountForExternalEffect(owner, allowImplicitDefault); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.Bus.UnregisterUser(userCommandID(deleted.Name), deleted.Name)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
|
||||
}
|
||||
@@ -140,7 +163,7 @@ func (s *Server) excludeMessage(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeOKOr(w, s.st.ExcludeRunGroup(msg.ID, excluded))
|
||||
writeOKOr(w, s.st.ExcludeRunGroupAccountChecked(s.owner(r), msg.ID, excluded, s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
func (s *Server) selectVariant(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -153,7 +176,7 @@ func (s *Server) selectVariant(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeOKOr(w, s.st.SelectVariant(msg.ID))
|
||||
writeOKOr(w, s.st.SelectVariantAccountChecked(s.owner(r), msg.ID, s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -187,11 +210,7 @@ func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var user core.Message
|
||||
found := false
|
||||
maxIndex := target.VariantIndex
|
||||
for _, m := range hist {
|
||||
if m.VariantGroup != "" && m.VariantGroup == target.VariantGroup && m.VariantIndex > maxIndex {
|
||||
maxIndex = m.VariantIndex
|
||||
}
|
||||
if m.ID == target.ID {
|
||||
found = true
|
||||
}
|
||||
@@ -203,19 +222,8 @@ func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "no user turn to regenerate", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
group := target.VariantGroup
|
||||
if group == "" {
|
||||
group = "var_" + target.ID
|
||||
}
|
||||
if err := s.st.SetMessageFlags(target.ID, true, group, target.VariantIndex); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.st.ExcludeRunGroup(target.ID, true); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.st.SetRegenPending(convID, group, maxIndex+1); err != nil {
|
||||
group, index, err := s.st.PrepareRegenerationAccountChecked(owner, convID, target.ID, s.Auth == nil || !s.Auth.Enabled)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -224,7 +232,7 @@ func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
run, err := s.mgr.StartConversationRun(conv, user, nil, nil)
|
||||
if err != nil {
|
||||
_, _, _ = s.st.TakeRegenPending(convID)
|
||||
_ = s.st.ClearRegenPendingAfterFailedStart(owner, convID, group, index, s.Auth == nil || !s.Auth.Enabled)
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -281,6 +289,11 @@ func (s *Server) appendUserPromptCommands(owner string, out []map[string]any) []
|
||||
return out
|
||||
}
|
||||
for _, c := range list {
|
||||
if s.Bus != nil {
|
||||
if err := s.st.CheckEffectiveAccountForExternalEffect(owner, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
return out
|
||||
}
|
||||
}
|
||||
s.registerUserPrompt(c)
|
||||
out = append(out, map[string]any{
|
||||
"id": userCommandID(c.Name), "name": c.Name, "title": c.Title,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func TestBorrowWritesCannotCommitAfterAccountDisable(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, path, body, table string
|
||||
}{
|
||||
{"secret", "/api/secrets", `{"service":"late","value":"hidden"}`, "secret_keys"},
|
||||
{"context", "/api/context-entries", `{"content":"late"}`, "context_entries"},
|
||||
{"command", "/api/prompt-commands", `{"name":"late","template":"late"}`, "user_commands"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
_ = bootAdmin(t, h, st)
|
||||
key, err := createLegacyAPIKeyFixture(t, st, "actor", "borrow")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account, err := st.GetAccountByOwner("actor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := &pausedRequestBody{reader: strings.NewReader(tc.body), entered: make(chan struct{}), release: make(chan struct{})}
|
||||
req := httptest.NewRequest(http.MethodPost, tc.path, body)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec := httptest.NewRecorder()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
h.ServeHTTP(rec, req)
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-body.entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("request did not reach its body")
|
||||
}
|
||||
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(body.release)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("request did not finish")
|
||||
}
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("late write status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var count int
|
||||
if err := st.DB().QueryRow("SELECT COUNT(*) FROM " + tc.table + " WHERE owner='actor'").Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("late rows=%d err=%v", count, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBorrowImplicitLocalCannotWriteWhenExplicitlyDisabled(t *testing.T) {
|
||||
srv, st := newTestServer(t)
|
||||
if err := st.CreateAccount("admin", "", "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret, err := st.AddSecret(store.OwnerDefault, "service", "original", "hidden")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry, err := st.SaveContextEntry(store.OwnerDefault, store.ContextEntry{Content: "original"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command, err := st.SaveUserCommand(store.OwnerDefault, store.UserCommand{Name: "original", Template: "original"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.EnsureUser(store.OwnerDefault, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account, err := st.GetAccountByOwner(store.OwnerDefault)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct{ method, path, body string }{
|
||||
{http.MethodPost, "/api/secrets", `{"service":"late","value":"hidden"}`},
|
||||
{http.MethodPost, "/api/secrets/" + secret.ID + "/activate", ""},
|
||||
{http.MethodPatch, "/api/secrets/" + secret.ID, `{"label":"late"}`},
|
||||
{http.MethodDelete, "/api/secrets/" + secret.ID, ""},
|
||||
{http.MethodPost, "/api/context-entries", `{"content":"late"}`},
|
||||
{http.MethodDelete, "/api/context-entries/" + entry.ID, ""},
|
||||
{http.MethodPost, "/api/prompt-commands", `{"name":"late","template":"late"}`},
|
||||
{http.MethodDelete, "/api/prompt-commands/" + command.ID, ""},
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("disabled local account wrote %s: %d %s", tc.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if value, ok := st.ActiveSecret(store.OwnerDefault, "service"); !ok || value != "hidden" {
|
||||
t.Fatalf("rejected secret mutations changed active value: %q %v", value, ok)
|
||||
}
|
||||
entries, err := st.ListContextEntries(store.OwnerDefault, "", "")
|
||||
if err != nil || len(entries) != 1 || entries[0].Content != "original" {
|
||||
t.Fatalf("rejected context mutations changed rows: %+v %v", entries, err)
|
||||
}
|
||||
commands, err := st.ListUserCommands(store.OwnerDefault)
|
||||
if err != nil || len(commands) != 1 || commands[0].Template != "original" {
|
||||
t.Fatalf("rejected command mutations changed rows: %+v %v", commands, err)
|
||||
}
|
||||
srv.appendUserPromptCommands(store.OwnerDefault, nil)
|
||||
for _, registered := range srv.Bus.List() {
|
||||
if registered.Spec.ID == "user.original" {
|
||||
t.Fatal("disabled account registered a command during listing")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBorrowPromptCommandBusFollowsCommittedWrite(t *testing.T) {
|
||||
srv, st := newTestServer(t)
|
||||
h := srv.Handler()
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/prompt-commands", strings.NewReader(`{"name":"local","template":"hello"}`)))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("save command: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
commands, err := st.ListUserCommands(store.OwnerDefault)
|
||||
if err != nil || len(commands) != 1 {
|
||||
t.Fatalf("saved command missing: %+v %v", commands, err)
|
||||
}
|
||||
registered := false
|
||||
for _, command := range srv.Bus.List() {
|
||||
registered = registered || command.Spec.ID == "user.local"
|
||||
}
|
||||
if !registered {
|
||||
t.Fatal("committed command was not registered in the Bus")
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/api/prompt-commands/"+commands[0].ID, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete command: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, command := range srv.Bus.List() {
|
||||
if command.Spec.ID == "user.local" {
|
||||
t.Fatal("deleted command remained registered in the Bus")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func TestListPluginsMergesCompiled(t *testing.T) {
|
||||
{ID: "generative-ui", Commands: []string{"generative-ui.present"}},
|
||||
}
|
||||
h := srv.Handler()
|
||||
key, err := st.CreateAPIKey("alice", "e2e")
|
||||
key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
agentcommand "agent-command"
|
||||
@@ -59,6 +60,16 @@ func newTestServer(t *testing.T) (*Server, *store.Store) {
|
||||
profSvc := profiles.New(st)
|
||||
profSvc.EnsureDefault()
|
||||
mgr := runmgr.New(st, b, reg, profSvc, setSvc, &runtime.LLMRunner{}, &runtime.SubLLMRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{})
|
||||
mgr.SetAccountEffectiveAuthorization(func(_ context.Context, owner string) (bool, error) {
|
||||
account, err := st.GetAccountByOwner(owner)
|
||||
if errors.Is(err, store.ErrNotFound) && owner == store.OwnerDefault {
|
||||
return true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return account.Enabled, nil
|
||||
}, true)
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
os.MkdirAll(pluginDir, 0o755)
|
||||
ph := plugins.New(st, reg, setSvc, pluginDir)
|
||||
@@ -66,6 +77,7 @@ func newTestServer(t *testing.T) (*Server, *store.Store) {
|
||||
// 生产同款装配(app.go):卡片事件经 runmgr 事件缓冲持久化/发布——项目流
|
||||
//(project:<id> 伪键,D50)依赖它;此前为 no-op emitter,事件不入库。
|
||||
cardSvc := cardsvc.New(st, mgr.EmitCardEvent)
|
||||
cardSvc.SetAccountCheckedEmitter(mgr.EmitCardEventAccountChecked)
|
||||
srv.Cards = cardSvc
|
||||
mgr.SetCards(cardSvc) // run 内产卡(摄取/编排/submit_card)经 runmgr,与 app.go 装配一致
|
||||
srv.Bus = cmdbus.New(st, srv.Cards, nil)
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
func TestUploadPreIndexSwitch(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
alice, err := st.CreateAPIKey("alice", "e2e")
|
||||
alice, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -13,10 +13,14 @@ import (
|
||||
func TestRedeemLoginKeyRestrictedSession(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
if err := st.CreateAccount("carol", "", "", false); err != nil {
|
||||
if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, _, err := st.IssueLoginKey("carol", store.LoginKeyPurposeActivate, "test", 0)
|
||||
account, err := st.CreatePendingAccount("admin", "carol", store.AccountProfile{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -35,7 +34,7 @@ func bootAdmin(t *testing.T, h http.Handler, st *store.Store) string {
|
||||
if tok == "" {
|
||||
t.Fatal("missing session cookie")
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/password", strings.NewReader(`{"next":"admin-final-9"}`))
|
||||
req = httptest.NewRequest("POST", "/api/auth/password", strings.NewReader(`{"current":"boot-pass-00","next":"admin-final-9"}`))
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok})
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
@@ -89,145 +88,48 @@ func do(h http.Handler, method, path, body, cookie string) *httptest.ResponseRec
|
||||
func TestAdminLoginKeyRecoveryFlow(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
adminTok := bootAdmin(t, h, st)
|
||||
if err := st.CreateAccount("mallory", "", "", false); err != nil {
|
||||
admin := bootAdmin(t, h, st)
|
||||
hash, err := store.HashPassword("victim-old-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("pleb", "", "", false); err != nil {
|
||||
if err := st.CreateAccount("victim", "", hash, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 匿名/无会话不能签发。
|
||||
if rec := do(h, "POST", "/api/admin/users/mallory/login-keys", `{}`, "not-a-session"); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous issue: %d", rec.Code)
|
||||
}
|
||||
|
||||
// 管理员签发:返回明文一次 + 元数据。
|
||||
rec := do(h, "POST", "/api/admin/users/mallory/login-keys", `{}`, adminTok)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("issue: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var issued struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &issued); err != nil {
|
||||
legacy, _, err := st.IssueLoginKey("victim", store.LoginKeyPurposeRecover, "legacy-admin", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(issued.Key, "alk_") || issued.ID == "" {
|
||||
t.Fatalf("issue payload: %+v", issued)
|
||||
if rec := do(h, "POST", "/api/admin/users/victim/login-keys", `{}`, admin); rec.Code != http.StatusGone {
|
||||
t.Fatalf("administrator secret issuance must be closed: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 列表:状态可见、无明文。
|
||||
rec = do(h, "GET", "/api/admin/users/mallory/login-keys", "", adminTok)
|
||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "alk_") {
|
||||
t.Fatalf("list must not leak plaintext: %d %s", rec.Code, rec.Body.String())
|
||||
if rec := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+legacy+`"}`, ""); rec.Code == http.StatusOK {
|
||||
t.Fatal("legacy administrator key redeemed after cutover")
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"status":"unused"`) {
|
||||
t.Fatalf("list should show unused status: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// 用户兑换:只提交 Key,身份由服务端绑定决定。
|
||||
req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+issued.Key+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"owner":"mallory"`) ||
|
||||
!strings.Contains(rec.Body.String(), `"sessionScope":"set_password_only"`) {
|
||||
t.Fatalf("redeem: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
userTok := cookieOf(rec)
|
||||
|
||||
// 重复兑换失败。
|
||||
req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+issued.Key+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("replay redeem: %d", rec.Code)
|
||||
}
|
||||
|
||||
// 受限会话:业务与管理员入口均拒绝。
|
||||
if rec := do(h, "GET", "/api/projects", "", userTok); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("business api: %d", rec.Code)
|
||||
}
|
||||
if rec := do(h, "POST", "/api/admin/users/pleb/login-keys", `{}`, userTok); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("restricted session must not issue keys: %d", rec.Code)
|
||||
}
|
||||
|
||||
// 设密(无 current)→ reloginRequired → 新密码登录。
|
||||
rec = do(h, "POST", "/api/auth/password", `{"next":"mallory-final-9"}`, userTok)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"reloginRequired":true`) {
|
||||
t.Fatalf("set password: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"mallory","password":"mallory-final-9"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
|
||||
t.Fatalf("login with new password: %d %s", rec.Code, rec.Body.String())
|
||||
if rec := do(h, "GET", "/api/admin/users/victim/login-keys", "", admin); rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), legacy) {
|
||||
t.Fatalf("metadata lookup leaked secret: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 补发与撤销:旧 Key 作废、旧受限会话失效。
|
||||
func TestAdminLoginKeyReissueAndRevoke(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
adminTok := bootAdmin(t, h, st)
|
||||
if err := st.CreateAccount("nia", "", "", false); err != nil {
|
||||
admin := bootAdmin(t, h, st)
|
||||
if err := st.CreateAccount("victim", "", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issue := func() (string, string) {
|
||||
rec := do(h, "POST", "/api/admin/users/nia/login-keys", `{}`, adminTok)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("issue: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
return out.ID, out.Key
|
||||
legacy, meta, err := st.IssueLoginKey("victim", store.LoginKeyPurposeRecover, "legacy-admin", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldID, oldKey := issue()
|
||||
_ = oldID
|
||||
|
||||
// 兑换旧 Key 得受限会话;补发后该会话失效、旧 Key 不可再用。
|
||||
req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+oldKey+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
oldSess := cookieOf(rec)
|
||||
if oldSess == "" {
|
||||
t.Fatal("redeem old key failed")
|
||||
if rec := do(h, "DELETE", "/api/admin/users/other/login-keys/"+meta.ID, "", admin); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("path owner mismatch revoked key: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
issue()
|
||||
if rec := do(h, "GET", "/api/me", "", oldSess); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("restricted session must die after reissue: %d", rec.Code)
|
||||
if rec := do(h, "DELETE", "/api/admin/users/victim/login-keys/"+meta.ID, "", admin); rec.Code != http.StatusOK {
|
||||
t.Fatalf("legacy metadata revoke: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+oldKey+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatal("consumed old key must not redeem again")
|
||||
}
|
||||
|
||||
// 撤销端点:未用 Key 撤销后不可兑换。
|
||||
newID, newKey := issue()
|
||||
if rec := do(h, "DELETE", "/api/admin/users/nia/login-keys/"+newID, "", adminTok); rec.Code != http.StatusOK {
|
||||
t.Fatalf("revoke: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+newKey+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatal("revoked key must not redeem")
|
||||
}
|
||||
// 不存在的编号 404。
|
||||
if rec := do(h, "DELETE", "/api/admin/users/nia/login-keys/lk_nope", "", adminTok); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown key revoke: %d", rec.Code)
|
||||
if rec := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+legacy+`"}`, ""); rec.Code == http.StatusOK {
|
||||
t.Fatal("revoked legacy key redeemed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +155,7 @@ func TestLoginMustChangeCreatesRestrictedSession(t *testing.T) {
|
||||
if rec := do(h, "GET", "/api/projects", "", tok); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("business api before set: %d", rec.Code)
|
||||
}
|
||||
rec = do(h, "POST", "/api/auth/password", `{"next":"oscar-final-9"}`, tok)
|
||||
rec = do(h, "POST", "/api/auth/password", `{"current":"temp-pass-00","next":"oscar-final-9"}`, tok)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("set password: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -271,10 +173,14 @@ func TestActiveChangePasswordRequiresCurrent(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
_ = srv
|
||||
if err := st.CreateAccount("petra", "", "", false); err != nil {
|
||||
if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, _, err := st.IssueLoginKey("petra", store.LoginKeyPurposeActivate, "test", 0)
|
||||
account, err := st.CreatePendingAccount("admin", "petra", store.AccountProfile{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@ func (s *Server) createBackgroundTask(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "kind required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
task, err := s.st.CreateBackgroundTask(s.owner(r), body.Kind, body.Title, body.ConversationID)
|
||||
task, err := s.st.CreateBackgroundTaskAccountChecked(s.owner(r), body.Kind, body.Title, body.ConversationID, s.Auth == nil || !s.Auth.Enabled)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, task)
|
||||
@@ -59,8 +59,8 @@ func (s *Server) updateBackgroundTask(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "status required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.st.UpdateBackgroundTask(s.owner(r), r.PathValue("id"), body.Status, body.Progress, body.Error); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
if err := s.st.UpdateBackgroundTaskAccountChecked(s.owner(r), r.PathValue("id"), body.Status, body.Progress, body.Error, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.getBackgroundTask(w, r)
|
||||
@@ -81,9 +81,9 @@ func (s *Server) putMemory(w http.ResponseWriter, r *http.Request) {
|
||||
if e.Source == "" {
|
||||
e.Source = "manual"
|
||||
}
|
||||
saved, err := s.st.PutMemory(s.owner(r), e)
|
||||
saved, err := s.st.PutMemoryAccountChecked(s.owner(r), e, s.Auth == nil || !s.Auth.Enabled)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, saved)
|
||||
@@ -119,11 +119,11 @@ func (s *Server) postAudit(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "action required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.st.AppendAuditEvent(store.AuditEvent{
|
||||
if err := s.st.AppendAuditEventAccountChecked(s.owner(r), store.AuditEvent{
|
||||
Owner: s.owner(r), Actor: s.owner(r), Action: body.Action, Target: body.Target,
|
||||
Evidence: body.Evidence, TraceID: r.Header.Get("X-Trace-Id"),
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -137,8 +137,8 @@ func (s *Server) setPluginEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.st.SetPluginEnabled(s.owner(r), r.PathValue("id"), body.Enabled); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
if err := s.st.SetPluginEnabledAccountChecked(s.owner(r), r.PathValue("id"), body.Enabled, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -210,11 +210,11 @@ func (s *Server) submitSurface(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "pluginId and spec required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
snap, err := s.Surfaces.Submit(r.Context(), s.owner(r), body.PluginID, surfacesvc.SubmitRequest{
|
||||
snap, err := s.Surfaces.SubmitAccountChecked(r.Context(), s.owner(r), body.PluginID, surfacesvc.SubmitRequest{
|
||||
SurfaceID: core.ID(body.SurfaceID), ConversationID: core.ID(body.ConversationID),
|
||||
BaseRevision: body.BaseRevision, ObservedEpoch: body.ObservedEpoch,
|
||||
ContextVersion: body.ContextVersion, SemanticText: body.SemanticText, Spec: body.Spec,
|
||||
})
|
||||
}, s.Auth == nil || !s.Auth.Enabled)
|
||||
if err != nil {
|
||||
s.writeSurfaceError(w, err)
|
||||
return
|
||||
@@ -260,7 +260,7 @@ func (s *Server) setSurfaceActivation(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "enabled required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
row, err := s.Surfaces.SetActivation(r.Context(), s.owner(r), r.PathValue("id"), body.Enabled)
|
||||
row, err := s.Surfaces.SetActivationAccountChecked(r.Context(), s.owner(r), r.PathValue("id"), body.Enabled, s.Auth == nil || !s.Auth.Enabled)
|
||||
if err != nil {
|
||||
s.writeSurfaceError(w, err)
|
||||
return
|
||||
@@ -299,10 +299,10 @@ func (s *Server) submitSurfaceAction(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "actionId and requestId required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
res, err := s.Surfaces.SubmitAction(r.Context(), s.owner(r), surfacesvc.SubmitActionRequest{
|
||||
res, err := s.Surfaces.SubmitActionAccountChecked(r.Context(), s.owner(r), surfacesvc.SubmitActionRequest{
|
||||
SurfaceID: core.ID(r.PathValue("id")), ActionID: body.ActionID,
|
||||
Revision: body.Revision, RequestID: body.RequestID, Data: body.Data,
|
||||
})
|
||||
}, s.Auth == nil || !s.Auth.Enabled)
|
||||
if err != nil {
|
||||
s.writeSurfaceError(w, err)
|
||||
return
|
||||
|
||||
@@ -20,7 +20,9 @@ func projectStreamReplay(t *testing.T, s *Server, key, project string) *httptest
|
||||
t.Helper()
|
||||
r := httptest.NewRequest("GET", "/api/events?scope=project&projectId="+project+"&since=0", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+key)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond)
|
||||
// Replay can pass more than a thousand private events before the first
|
||||
// visible frame; account checks on CI and under -race need headroom.
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, r.WithContext(ctx))
|
||||
@@ -29,10 +31,11 @@ func projectStreamReplay(t *testing.T, s *Server, key, project string) *httptest
|
||||
func projectEventFixture(t *testing.T) (*Server, string, string, string, *atomic.Bool) {
|
||||
t.Helper()
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
|
||||
bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent)
|
||||
s.Cards.SetAccountCheckedEmitter(s.mgr.EmitCardEventAccountChecked)
|
||||
s.Cards.SetProjectSharedEmitter(s.mgr.EmitProjectSharedCardEvent)
|
||||
member := &atomic.Bool{}
|
||||
member.Store(true)
|
||||
@@ -77,7 +80,8 @@ func TestProjectEventsSharedProofAndPrivatePagination(t *testing.T) {
|
||||
}
|
||||
w := projectStreamReplay(t, s, bob, p)
|
||||
if w.Code != 200 || bytes.Contains(w.Body.Bytes(), []byte("PRIVATE HISTORY")) || !bytes.Contains(w.Body.Bytes(), []byte("EXPLICIT SHARED")) {
|
||||
t.Fatal("private pagination/spoof proof failed")
|
||||
t.Fatalf("private pagination/spoof proof failed: code=%d private=%t shared=%t bytes=%d", w.Code,
|
||||
bytes.Contains(w.Body.Bytes(), []byte("PRIVATE HISTORY")), bytes.Contains(w.Body.Bytes(), []byte("EXPLICIT SHARED")), w.Body.Len())
|
||||
}
|
||||
}
|
||||
func TestProjectEventsLivePrivacyAndRevocation(t *testing.T) {
|
||||
|
||||
@@ -45,11 +45,11 @@ func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID
|
||||
if err := s.projectWork(ctx, owner, projectID); err != nil {
|
||||
return core.Conversation{}, err
|
||||
}
|
||||
return s.st.CreateConversationInProject(owner, projectID, title, profile)
|
||||
return s.st.CreateConversationAccountChecked(owner, projectID, title, profile, s.Auth == nil || !s.Auth.Enabled, false)
|
||||
}
|
||||
c, err := s.st.CreateConversation(owner, projectID, title, profile)
|
||||
c, err := s.st.CreateConversationAccountChecked(owner, projectID, title, profile, s.Auth == nil || !s.Auth.Enabled, true)
|
||||
if errors.Is(err, store.ErrNotFound) && s.authorizeSharedResource(owner, "read", "project", core.ID(projectID), "", core.ID(projectID)) {
|
||||
return s.st.CreateConversationInProject(owner, projectID, title, profile)
|
||||
return s.st.CreateConversationAccountChecked(owner, projectID, title, profile, s.Auth == nil || !s.Auth.Enabled, false)
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func lifecycleRequest(t *testing.T, s *Server, key, method, path, body string) *
|
||||
}
|
||||
func TestProjectLifecycleDelegatesAndPreservesArchiveHistory(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Original")
|
||||
c, _ := st.CreateConversation("alice", p.ID, "Private", "")
|
||||
calls := []string{}
|
||||
@@ -74,9 +74,9 @@ func TestProjectLifecycleDelegatesAndPreservesArchiveHistory(t *testing.T) {
|
||||
func fmtWrap(err error) error { return errors.Join(errors.New("wrapped"), err) }
|
||||
func TestProjectLifecycleMemberPrivateWork(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
mallory, _ := st.CreateAPIKey("mallory", "test")
|
||||
alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
|
||||
bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
|
||||
mallory, _ := createLegacyAPIKeyFixture(t, st, "mallory", "test")
|
||||
p, _ := st.CreateProject("alice", "Shared")
|
||||
ac, _ := st.CreateConversation("alice", p.ID, "Alice private", "")
|
||||
ready := true
|
||||
@@ -126,7 +126,7 @@ func TestProjectLifecycleMemberPrivateWork(t *testing.T) {
|
||||
}
|
||||
func TestProjectLifecycleReadOnlyAuthorizerDoesNotGrantWrite(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
|
||||
p, _ := st.CreateProject("alice", "Shared")
|
||||
s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil)
|
||||
@@ -140,7 +140,7 @@ func TestProjectLifecycleReadOnlyAuthorizerDoesNotGrantWrite(t *testing.T) {
|
||||
|
||||
func TestProjectLifecycleRevocationGuardsExecutionResumption(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
conv, _ := st.CreateConversation("alice", p.ID, "Private", "")
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return false, nil }}, nil)
|
||||
@@ -158,7 +158,7 @@ func TestProjectLifecycleRevocationGuardsExecutionResumption(t *testing.T) {
|
||||
|
||||
func TestProjectLifecycleRevocationGuardsSurfaceAction(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
conv, _ := st.CreateConversation("alice", p.ID, "Private", "")
|
||||
svc := surfacesvc.New(st, nil)
|
||||
@@ -179,7 +179,7 @@ func TestProjectLifecycleRevocationGuardsSurfaceAction(t *testing.T) {
|
||||
|
||||
func TestProjectLifecycleSettingsRetainServiceMetadata(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
|
||||
current, err := st.GetProject("alice", p.ID)
|
||||
@@ -203,8 +203,8 @@ func TestProjectLifecycleSettingsRetainServiceMetadata(t *testing.T) {
|
||||
|
||||
func TestProjectLifecyclePublicMetadataMasksSecrets(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
|
||||
bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
|
||||
p, _ := st.CreateProject("alice", "Shared")
|
||||
raw := map[string]any{"models.chat.apiKey": "fixture-key", "custom": map[string]any{"token": "fixture-token"}, "custom.api_key": "fixture-flat-token", "plugin.private.credential": "fixture-schema-token", "display": map[string]any{"caption": "Shared"}, "rdm.projectId": "canonical", "rdm.state": "ready"}
|
||||
if err := st.UpdateProjectSettings("alice", p.ID, raw); err != nil {
|
||||
@@ -243,7 +243,7 @@ func TestGenericProjectPatchRejectsManagedSettingsAtomically(t *testing.T) {
|
||||
for _, registered := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(registered), func(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Original")
|
||||
s.set.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.managed", Scope: core.SettingScopeSystem}})
|
||||
if registered {
|
||||
@@ -286,7 +286,7 @@ func TestGenericProjectPatchAcceptsNestedMaskedWriteback(t *testing.T) {
|
||||
for _, registered := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(registered), func(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
if err := st.UpdateProjectSettings("alice", p.ID, map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "fixture-persisted-key"}}, "display": map[string]any{"caption": "Original"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -347,9 +347,9 @@ func lifecycleUpload(t *testing.T, s *Server, key, projectID, name, content stri
|
||||
|
||||
func TestProjectLifecycleMemberUploadStaysPrivate(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
mallory, _ := st.CreateAPIKey("mallory", "test")
|
||||
alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
|
||||
bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
|
||||
mallory, _ := createLegacyAPIKeyFixture(t, st, "mallory", "test")
|
||||
p, _ := st.CreateProject("alice", "Shared")
|
||||
ready := true
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
|
||||
@@ -399,8 +399,8 @@ func TestProjectLifecycleMemberUploadStaysPrivate(t *testing.T) {
|
||||
|
||||
func TestProjectLifecycleUploadSuppressesPrivateProjectCards(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
|
||||
bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
|
||||
p, _ := st.CreateProject("alice", "Shared")
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
|
||||
return id == p.ID && (actor == "alice" || actor == "bob"), nil
|
||||
@@ -409,6 +409,7 @@ func TestProjectLifecycleUploadSuppressesPrivateProjectCards(t *testing.T) {
|
||||
return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || q.Actor == "bob"), nil
|
||||
})
|
||||
s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent)
|
||||
s.Cards.SetAccountCheckedEmitter(s.mgr.EmitCardEventAccountChecked)
|
||||
s.Cards.SetProjectSharedEmitter(s.mgr.EmitProjectSharedCardEvent)
|
||||
s.DisableUploadIndexing = true
|
||||
if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p.ID, Title: "Shared structure", Kind: core.CardKindProgress}, ""); err != nil {
|
||||
@@ -468,9 +469,10 @@ func TestProjectLifecycleUploadSuppressesPrivateProjectCards(t *testing.T) {
|
||||
|
||||
func TestUnregisteredUploadKeepsLegacyCardsAndValidation(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Private")
|
||||
s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent)
|
||||
s.Cards.SetAccountCheckedEmitter(s.mgr.EmitCardEventAccountChecked)
|
||||
s.DisableUploadIndexing = true
|
||||
if w := lifecycleUpload(t, s, key, p.ID, "legacy-upload.txt", "Original"); w.Code != 200 {
|
||||
t.Fatalf("unregistered owner upload=%d", w.Code)
|
||||
@@ -486,7 +488,7 @@ func TestUnregisteredUploadKeepsLegacyCardsAndValidation(t *testing.T) {
|
||||
|
||||
func TestProjectLifecycleUploadRechecksBeforePersistence(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
|
||||
p, _ := st.CreateProject("alice", "Shared")
|
||||
ready := true
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
func TestKnowledgeV2UsesDocumentIdentity(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
|
||||
docs := []core.KnowledgeDoc{{ID: "published-slice", Owner: "alice", ProjectID: "domain-p", Chunk: "sharedneedle published"}, {ID: "private-draft", Owner: "alice", ProjectID: "domain-p", Chunk: "sharedneedle draft"}, {ID: "self-denied", Owner: "bob", ProjectID: "domain-p", Chunk: "sharedneedle own restricted"}, {ID: "self-allowed", Owner: "bob", ProjectID: "domain-p", Chunk: "sharedneedle own"}}
|
||||
if err := st.AddKnowledge(docs...); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -39,7 +39,7 @@ func TestKnowledgeV2UsesDocumentIdentity(t *testing.T) {
|
||||
}
|
||||
func TestArtifactListV2FiltersEachResource(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
private := core.Artifact{Owner: "alice", ProjectID: p.ID, Name: "Private", MIME: "text/plain"}
|
||||
public := core.Artifact{Owner: "alice", ProjectID: p.ID, Name: "Shared", MIME: "text/plain"}
|
||||
@@ -62,7 +62,7 @@ func TestArtifactListV2FiltersEachResource(t *testing.T) {
|
||||
|
||||
func TestKnowledgeV2ResolvesTrustedProjectScope(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "alice", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "alice", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func TestKnowledgeV2ResolvesTrustedProjectScope(t *testing.T) {
|
||||
|
||||
func TestKnowledgeV2UsesStoredPublicationSource(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
|
||||
// A known published identity replaced by a private row must lose sharing.
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "known-published-id", Owner: "alice", ProjectID: "domain", Source: "manual", Chunk: "sourceneedle private"}, core.KnowledgeDoc{ID: "real-published-id", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Chunk: "sourceneedle public"}); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (s *Server) createAdminRecoveryProposal(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ExpectedStateVersion int64 `json:"expectedStateVersion"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
}
|
||||
if err := decodeStrictJSON(r, &req); err != nil {
|
||||
http.Error(w, "invalid recovery proposal", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
proposal, err := s.st.CreateRecoveryProposal(actor, r.PathValue("userId"), req.ExpectedStateVersion, req.IdempotencyKey)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, proposal)
|
||||
}
|
||||
|
||||
func (s *Server) getAdminRecoveryProposal(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
proposal, err := s.st.GetRecoveryProposalForAdmin(actor, r.PathValue("userId"), r.PathValue("id"))
|
||||
writeJSONOr(w, proposal, err)
|
||||
}
|
||||
|
||||
func (s *Server) listAdminRecoveryProposals(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
proposals, err := s.st.ListRecoveryProposalsForAdmin(actor, r.PathValue("userId"))
|
||||
writeJSONOr(w, map[string]any{"proposals": proposals}, err)
|
||||
}
|
||||
|
||||
func (s *Server) revokeAdminRecoveryProposal(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ExpectedVersion int64 `json:"expectedVersion"`
|
||||
}
|
||||
if err := decodeStrictJSON(r, &req); err != nil {
|
||||
http.Error(w, "invalid recovery revocation", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
proposal, err := s.st.RevokeRecoveryProposal(actor, r.PathValue("userId"), r.PathValue("id"), req.ExpectedVersion)
|
||||
writeJSONOr(w, proposal, err)
|
||||
}
|
||||
|
||||
func (s *Server) listMyRecoveryProposals(w http.ResponseWriter, r *http.Request) {
|
||||
owner, ok := s.requireFullPasswordSession(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
proposals, err := s.st.ListOwnerRecoveryProposals(owner)
|
||||
writeJSONOr(w, map[string]any{"proposals": proposals}, err)
|
||||
}
|
||||
|
||||
func (s *Server) getMyRecoveryProposal(w http.ResponseWriter, r *http.Request) {
|
||||
owner, ok := s.requireFullPasswordSession(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
proposal, err := s.st.GetOwnerRecoveryProposal(owner, r.PathValue("id"))
|
||||
writeJSONOr(w, proposal, err)
|
||||
}
|
||||
|
||||
func (s *Server) confirmRecoveryProposal(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireFullPasswordSession(w, r); !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ExpectedVersion int64 `json:"expectedVersion"`
|
||||
}
|
||||
if err := decodeStrictJSON(r, &req); err != nil {
|
||||
http.Error(w, "invalid recovery confirmation", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
plain, proposal, err := s.st.ConfirmRecoveryProposal(sessionToken(r), r.PathValue("id"), req.ExpectedVersion)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
out := map[string]any{"proposal": proposal}
|
||||
if plain != "" {
|
||||
out["grant"] = plain
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) rotateRecoveryGrant(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireFullPasswordSession(w, r); !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ExpectedVersion int64 `json:"expectedVersion"`
|
||||
ExpectedGeneration int64 `json:"expectedGeneration"`
|
||||
}
|
||||
if err := decodeStrictJSON(r, &req); err != nil {
|
||||
http.Error(w, "invalid recovery grant rotation", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
plain, proposal, err := s.st.RotateRecoveryGrant(sessionToken(r), r.PathValue("id"), req.ExpectedVersion, req.ExpectedGeneration)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
out := map[string]any{"proposal": proposal}
|
||||
if plain != "" {
|
||||
out["grant"] = plain
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func TestOwnerConfirmedRecoveryHTTPKeepsGrantFromAdministrator(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
admin := bootAdmin(t, h, st)
|
||||
pending, err := st.CreatePendingAccount("admin", "recovery-user", store.AccountProfile{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
activation, err := st.InitializePendingAccount(pending.ID, pending.StateVersion, pending.InitializationGeneration)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setup := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+activation+`"}`, "")
|
||||
if setup.Code != http.StatusOK || !strings.Contains(setup.Body.String(), `"passwordChangeMode":"grant_setup"`) {
|
||||
t.Fatalf("activation redeem: %d %s", setup.Code, setup.Body.String())
|
||||
}
|
||||
if rec := do(h, "POST", "/api/auth/password", `{"next":"alice-initial-password"}`, cookieOf(setup)); rec.Code != http.StatusOK {
|
||||
t.Fatalf("initial set: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
ownerLogin := do(h, "POST", "/api/auth/login", `{"owner":"recovery-user","password":"alice-initial-password"}`, "")
|
||||
if ownerLogin.Code != http.StatusOK {
|
||||
t.Fatalf("trusted owner login: %d %s", ownerLogin.Code, ownerLogin.Body.String())
|
||||
}
|
||||
ownerCookie := cookieOf(ownerLogin)
|
||||
account, err := st.GetAccount(pending.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proposalResponse := do(h, "POST", "/api/admin/users/"+pending.ID+"/recovery-proposals",
|
||||
`{"expectedStateVersion":`+jsonNumber(account.StateVersion)+`,"idempotencyKey":"http-request-1"}`, admin)
|
||||
if proposalResponse.Code != http.StatusOK || strings.Contains(proposalResponse.Body.String(), "grant") {
|
||||
t.Fatalf("administrator proposal leaked grant: %d %s", proposalResponse.Code, proposalResponse.Body.String())
|
||||
}
|
||||
var proposal store.RecoveryProposal
|
||||
if err := json.Unmarshal(proposalResponse.Body.Bytes(), &proposal); err != nil || proposal.ID == "" {
|
||||
t.Fatalf("proposal metadata: %+v %v", proposal, err)
|
||||
}
|
||||
if got := do(h, "GET", "/api/admin/users/"+pending.ID+"/recovery-proposals/"+proposal.ID, "", admin); got.Code != http.StatusOK || strings.Contains(got.Body.String(), "grant") {
|
||||
t.Fatalf("administrator status leaked grant: %d %s", got.Code, got.Body.String())
|
||||
}
|
||||
confirmed := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/confirm", `{"expectedVersion":1}`, ownerCookie)
|
||||
if confirmed.Code != http.StatusOK || confirmed.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("owner confirm: %d %s", confirmed.Code, confirmed.Body.String())
|
||||
}
|
||||
var confirmation struct {
|
||||
Proposal store.RecoveryProposal `json:"proposal"`
|
||||
Grant string `json:"grant"`
|
||||
}
|
||||
if err := json.Unmarshal(confirmed.Body.Bytes(), &confirmation); err != nil || confirmation.Grant == "" {
|
||||
t.Fatalf("confirmation secret response: %v", err)
|
||||
}
|
||||
if duplicate := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/confirm", `{"expectedVersion":1}`, ownerCookie); duplicate.Code != http.StatusOK || strings.Contains(duplicate.Body.String(), `"grant"`) {
|
||||
t.Fatalf("duplicate confirmation replayed grant: %d %s", duplicate.Code, duplicate.Body.String())
|
||||
}
|
||||
if got := do(h, "GET", "/api/me/recovery-proposals/"+proposal.ID, "", ownerCookie); got.Code != http.StatusOK || strings.Contains(got.Body.String(), confirmation.Grant) {
|
||||
t.Fatalf("owner status replayed grant: %d %s", got.Code, got.Body.String())
|
||||
}
|
||||
rotated := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/rotate-grant",
|
||||
`{"expectedVersion":`+jsonNumber(confirmation.Proposal.Version)+`,"expectedGeneration":`+jsonNumber(confirmation.Proposal.Generation)+`}`, ownerCookie)
|
||||
if rotated.Code != http.StatusOK {
|
||||
t.Fatalf("owner rotate: %d %s", rotated.Code, rotated.Body.String())
|
||||
}
|
||||
var rotation struct {
|
||||
Grant string `json:"grant"`
|
||||
}
|
||||
if err := json.Unmarshal(rotated.Body.Bytes(), &rotation); err != nil || rotation.Grant == "" || rotation.Grant == confirmation.Grant {
|
||||
t.Fatalf("rotation grant: %v", err)
|
||||
}
|
||||
if duplicate := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/rotate-grant",
|
||||
`{"expectedVersion":`+jsonNumber(confirmation.Proposal.Version)+`,"expectedGeneration":`+jsonNumber(confirmation.Proposal.Generation)+`}`, ownerCookie); duplicate.Code != http.StatusOK || strings.Contains(duplicate.Body.String(), `"grant"`) {
|
||||
t.Fatalf("duplicate rotation replayed grant: %d %s", duplicate.Code, duplicate.Body.String())
|
||||
}
|
||||
if old := do(h, "POST", "/api/auth/recovery-grants/redeem", `{"key":"`+confirmation.Grant+`"}`, ""); old.Code == http.StatusOK {
|
||||
t.Fatal("old recovery grant remained usable")
|
||||
}
|
||||
redeemed := do(h, "POST", "/api/auth/recovery-grants/redeem", `{"key":"`+rotation.Grant+`"}`, "")
|
||||
if redeemed.Code != http.StatusOK {
|
||||
t.Fatalf("recovery redeem: %d %s", redeemed.Code, redeemed.Body.String())
|
||||
}
|
||||
if done := do(h, "POST", "/api/auth/password", `{"next":"alice-recovered-password"}`, cookieOf(redeemed)); done.Code != http.StatusOK {
|
||||
t.Fatalf("recovery finish: %d %s", done.Code, done.Body.String())
|
||||
}
|
||||
if login := do(h, "POST", "/api/auth/login", `{"owner":"recovery-user","password":"alice-recovered-password"}`, ""); login.Code != http.StatusOK {
|
||||
t.Fatalf("relogin: %d %s", login.Code, login.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func jsonNumber(v int64) string { return strconv.FormatInt(v, 10) }
|
||||
|
||||
func TestLegacyAdministratorRecoveryEntrancesAreClosed(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
admin := bootAdmin(t, h, st)
|
||||
hash, err := store.HashPassword("old-victim-pass")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("victim", "", hash, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.ClearMustChangePassword("victim"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, route := range []struct{ path, body string }{
|
||||
{"/api/admin/users/victim/password", `{"password":"attacker-pass"}`},
|
||||
{"/api/admin/users/victim/login-keys", `{"purpose":"recover"}`},
|
||||
{"/api/admin/users/victim/login-keys", `{"purpose":"activate"}`},
|
||||
} {
|
||||
rec := do(h, "POST", route.path, route.body, admin)
|
||||
if rec.Code != http.StatusGone || !strings.Contains(rec.Body.String(), "RECOVERY_CONTRACT_REPLACED") {
|
||||
t.Fatalf("legacy route remained active: %s %d %s", route.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if _, ok := st.VerifyLogin("victim", "old-victim-pass"); !ok {
|
||||
t.Fatal("closed routes changed existing password")
|
||||
}
|
||||
if keys, err := st.ListLoginKeys("victim"); err != nil || len(keys) != 0 {
|
||||
t.Fatalf("closed routes issued a grant: %+v %v", keys, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyGrantAndRestrictedSessionCannotSkipCurrentPassword(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
hash, err := store.HashPassword("old-victim-pass")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("victim", "", hash, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant, _, err := st.IssueLoginKey("victim", store.LoginKeyPurposeRecover, "legacy-admin", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+grant+`"}`, ""); rec.Code == http.StatusOK {
|
||||
t.Fatal("legacy administrator grant created a restricted session")
|
||||
}
|
||||
if err := st.SetUserPassword("victim", hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
login := do(h, "POST", "/api/auth/login", `{"owner":"victim","password":"old-victim-pass"}`, "")
|
||||
if login.Code != http.StatusOK || !strings.Contains(login.Body.String(), `"passwordChangeMode":"current_required"`) {
|
||||
t.Fatalf("legacy forced login mode: %d %s", login.Code, login.Body.String())
|
||||
}
|
||||
cookie := cookieOf(login)
|
||||
if rec := do(h, "POST", "/api/auth/password", `{"next":"victim-new-pass"}`, cookie); rec.Code == http.StatusOK {
|
||||
t.Fatal("forced password session skipped current password")
|
||||
}
|
||||
if rec := do(h, "POST", "/api/auth/password", `{"current":"old-victim-pass","next":"victim-new-pass"}`, cookie); rec.Code != http.StatusOK {
|
||||
t.Fatalf("legacy forced change with current failed: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok := st.VerifyLogin("victim", "victim-new-pass"); !ok {
|
||||
t.Fatal("current-required password change did not complete")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func twinRecoveryStores(t *testing.T) (*store.Store, *store.Store) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("AGENT_PG_TEST_DSN")
|
||||
if dsn == "" {
|
||||
path := filepath.Join(t.TempDir(), "shared.db")
|
||||
first, err := store.OpenSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := store.OpenSQLite(path)
|
||||
if err != nil {
|
||||
first.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { second.Close(); first.Close() })
|
||||
return first, second
|
||||
}
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
schema := "t_" + strings.ToLower(store.NewID("recovery"))
|
||||
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() })
|
||||
isolate := store.AppendPGRuntimeParam(dsn, "search_path", schema+",public")
|
||||
first, err := store.OpenPostgres(isolate, filepath.Join(t.TempDir(), "a-artifacts"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := store.OpenPostgres(isolate, filepath.Join(t.TempDir(), "b-artifacts"))
|
||||
if err != nil {
|
||||
first.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { second.Close(); first.Close() })
|
||||
return first, second
|
||||
}
|
||||
|
||||
func TestRecoveryAcrossTwoHTTPInstancesAndIndependentPools(t *testing.T) {
|
||||
first, second := twinRecoveryStores(t)
|
||||
t.Logf("recovery HTTP dialect=%s two independent stores", first.Dialect())
|
||||
hA := newAuthTestServerOnStore(t, first, t.TempDir()).Handler()
|
||||
hB := newAuthTestServerOnStore(t, second, t.TempDir()).Handler()
|
||||
adminCookie := bootAdmin(t, hA, first)
|
||||
account, err := first.CreatePendingAccount("admin", "two-instance-owner", store.AccountProfile{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
activation, err := first.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setup := do(hB, "POST", "/api/auth/login-key/redeem", `{"key":"`+activation+`"}`, "")
|
||||
if setup.Code != http.StatusOK {
|
||||
t.Fatalf("second instance activation redeem: %d %s", setup.Code, setup.Body.String())
|
||||
}
|
||||
if done := do(hA, "POST", "/api/auth/password", `{"next":"two-instance-initial"}`, cookieOf(setup)); done.Code != http.StatusOK {
|
||||
t.Fatalf("first instance initial set: %d %s", done.Code, done.Body.String())
|
||||
}
|
||||
login := do(hA, "POST", "/api/auth/login", `{"owner":"two-instance-owner","password":"two-instance-initial"}`, "")
|
||||
if login.Code != http.StatusOK {
|
||||
t.Fatalf("first instance login: %d %s", login.Code, login.Body.String())
|
||||
}
|
||||
ownerCookie := cookieOf(login)
|
||||
current, err := second.GetAccount(account.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proposalResponse := do(hA, "POST", "/api/admin/users/"+account.ID+"/recovery-proposals",
|
||||
`{"expectedStateVersion":`+jsonNumber(current.StateVersion)+`,"idempotencyKey":"two-instance-proposal"}`, adminCookie)
|
||||
if proposalResponse.Code != http.StatusOK {
|
||||
t.Fatalf("proposal: %d %s", proposalResponse.Code, proposalResponse.Body.String())
|
||||
}
|
||||
var proposal store.RecoveryProposal
|
||||
if err := json.Unmarshal(proposalResponse.Body.Bytes(), &proposal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
confirmation := do(hB, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/confirm", `{"expectedVersion":1}`, ownerCookie)
|
||||
if confirmation.Code != http.StatusOK {
|
||||
t.Fatalf("second instance confirm: %d %s", confirmation.Code, confirmation.Body.String())
|
||||
}
|
||||
var confirmed struct {
|
||||
Grant string `json:"grant"`
|
||||
}
|
||||
if err := json.Unmarshal(confirmation.Body.Bytes(), &confirmed); err != nil || confirmed.Grant == "" {
|
||||
t.Fatalf("grant response: %v", err)
|
||||
}
|
||||
redeemed := do(hA, "POST", "/api/auth/recovery-grants/redeem", `{"key":"`+confirmed.Grant+`"}`, "")
|
||||
if redeemed.Code != http.StatusOK {
|
||||
t.Fatalf("first instance grant redeem: %d %s", redeemed.Code, redeemed.Body.String())
|
||||
}
|
||||
if completed := do(hB, "POST", "/api/auth/password", `{"next":"two-instance-recovered"}`, cookieOf(redeemed)); completed.Code != http.StatusOK {
|
||||
t.Fatalf("second instance recovery completion: %d %s", completed.Code, completed.Body.String())
|
||||
}
|
||||
if old := do(hB, "GET", "/api/me", "", ownerCookie); old.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("old proof session survived: %d", old.Code)
|
||||
}
|
||||
if fresh := do(hA, "POST", "/api/auth/login", `{"owner":"two-instance-owner","password":"two-instance-recovered"}`, ""); fresh.Code != http.StatusOK {
|
||||
t.Fatalf("recovered password login: %d %s", fresh.Code, fresh.Body.String())
|
||||
}
|
||||
after, err := first.GetAccount(account.ID)
|
||||
if err != nil || after.CredentialOrigin != store.CredentialOriginOwnerRecovery || after.ID != account.ID {
|
||||
t.Fatalf("persistent identity or provenance changed: %+v %v", after, err)
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,11 @@ import (
|
||||
func newV2CaptureServer(t *testing.T) (*Server, *store.Store, string, string, string) {
|
||||
t.Helper()
|
||||
srv, st := newAuthTestServer(t)
|
||||
alice, err := st.CreateAPIKey("alice", "e2e")
|
||||
alice, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bob, err := st.CreateAPIKey("bob", "e2e")
|
||||
bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -19,15 +19,15 @@ func TestProjectResourceAuthorizerSharesReads(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
|
||||
alice, err := st.CreateAPIKey("alice", "e2e")
|
||||
alice, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bob, err := st.CreateAPIKey("bob", "e2e")
|
||||
bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
carol, err := st.CreateAPIKey("carol", "e2e")
|
||||
carol, err := createLegacyAPIKeyFixture(t, st, "carol", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func (s *Server) rewindFiles(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "messageId required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.mgr.ClaimConversation(id); err != nil {
|
||||
if err := s.mgr.ClaimConversation(id, owner); err != nil {
|
||||
if errors.Is(err, runmgr.ErrConversationBusy) {
|
||||
http.Error(w, "conversation busy", http.StatusConflict)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func TestRunProcessEventsAreOrderedAndBoundToConversation(t *testing.T) {
|
||||
srv, st := newTestServer(t)
|
||||
h := srv.Handler()
|
||||
_, convID := setupConversation(t, h)
|
||||
_, otherConvID := setupConversation(t, h)
|
||||
runID := postMessage(t, h, convID, "整理材料")
|
||||
waitRunTerminal(t, h, runID, 5*time.Second)
|
||||
events := []core.StreamEvent{
|
||||
{RunID: runID, Type: core.EvThinkingDelta, Payload: json.RawMessage(`{"index":0,"text":"先核对日期"}`)},
|
||||
{RunID: runID, Type: core.EvToolOutput, Payload: json.RawMessage(`{"callId":"call-1","stream":"stdout","text":"完整工具输出"}`)},
|
||||
}
|
||||
if _, err := st.AppendEvents(convID, events); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The runner may persist its terminal event after its status becomes terminal.
|
||||
// Anchor paging to the events this test appended, not an earlier snapshot.
|
||||
path := "/api/conversations/" + convID + "/runs/" + runID + "/events?after=" + strconv.FormatInt(events[0].Seq-1, 10) + "&limit=1"
|
||||
code, first := doJSON(t, h, http.MethodGet, path, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("first page: %d %v", code, first)
|
||||
}
|
||||
page := first["events"].([]any)
|
||||
if len(page) != 1 || page[0].(map[string]any)["type"] != string(core.EvThinkingDelta) {
|
||||
t.Fatalf("first page: %v", page)
|
||||
}
|
||||
seq := int64(page[0].(map[string]any)["seq"].(float64))
|
||||
code, second := doJSON(t, h, http.MethodGet, "/api/conversations/"+convID+"/runs/"+runID+"/events?after="+strconv.FormatInt(seq, 10)+"&limit=1", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("second page: %d %v", code, second)
|
||||
}
|
||||
page = second["events"].([]any)
|
||||
if len(page) != 1 || page[0].(map[string]any)["type"] != string(core.EvToolOutput) {
|
||||
t.Fatalf("second page: %v", page)
|
||||
}
|
||||
if payload := page[0].(map[string]any)["payload"].(map[string]any); payload["text"] != "完整工具输出" {
|
||||
t.Fatalf("payload: %v", payload)
|
||||
}
|
||||
parent, err := st.GetRunByID(runID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child := core.Run{ID: "run_child_process", ParentRunID: parent.ID, ConversationID: parent.ConversationID,
|
||||
ProjectID: parent.ProjectID, Owner: parent.Owner, Kind: core.RunSubagent, Status: core.RunCompleted}
|
||||
if err := st.CreateRun(&child); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
childEvents := []core.StreamEvent{
|
||||
{RunID: child.ID, Type: core.EvAgentProgress, Payload: json.RawMessage(`{"agentRun":"run_child_process","note":"正在核对附录"}`)},
|
||||
{RunID: child.ID, Type: core.EvToolOutput, Payload: json.RawMessage(`{"callId":"hidden","text":"child raw output"}`)},
|
||||
{RunID: child.ID, Type: core.EvAgentResult, Payload: json.RawMessage(`{"agentRun":"run_child_process","status":"completed","summary":"附录已核对"}`)},
|
||||
}
|
||||
if _, err := st.AppendEvents(convID, childEvents); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, childPage := doJSON(t, h, http.MethodGet, "/api/conversations/"+convID+"/runs/"+runID+"/events?after="+strconv.FormatInt(events[1].Seq, 10), nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("child page: %d %v", code, childPage)
|
||||
}
|
||||
childRows := childPage["events"].([]any)
|
||||
if len(childRows) != 2 || childRows[0].(map[string]any)["type"] != string(core.EvAgentProgress) || childRows[1].(map[string]any)["type"] != string(core.EvAgentResult) {
|
||||
t.Fatalf("child progress/result not retained or raw child output leaked: %v", childRows)
|
||||
}
|
||||
code, _ = doJSON(t, h, http.MethodGet, "/api/conversations/"+otherConvID+"/runs/"+runID+"/events", nil)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("mismatched conversation exposed run: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProcessEventsRejectsDisabledImplicitLocal(t *testing.T) {
|
||||
first, second := twinRecoveryStores(t)
|
||||
t.Logf("process-event replay dialect=%s independent stores", first.Dialect())
|
||||
if err := first.CreateAccount("admin", "", "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := first.EnsureUser(store.OwnerDefault, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, err := first.CreateProject(store.OwnerDefault, "Project")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversation, err := first.CreateConversation(store.OwnerDefault, project.ID, "Process", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := core.Run{ID: "run_local_process", ConversationID: conversation.ID, ProjectID: project.ID,
|
||||
Owner: store.OwnerDefault, Kind: core.RunConversation, Status: core.RunCompleted}
|
||||
if err := first.CreateRun(&run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := first.AppendEvents(conversation.ID, []core.StreamEvent{{RunID: run.ID, Type: core.EvThinkingDelta,
|
||||
Payload: json.RawMessage(`{"index":0,"text":"private process"}`)}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := newAuthTestServerOnStore(t, second, t.TempDir())
|
||||
h := srv.Handler()
|
||||
path := "/api/conversations/" + conversation.ID + "/runs/" + run.ID + "/events"
|
||||
code, _ := doJSON(t, h, http.MethodGet, path, nil)
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("auth-on replay without credential: %d", code)
|
||||
}
|
||||
srv.Auth.Enabled = false
|
||||
code, _ = doJSON(t, h, http.MethodGet, path, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("enabled local account could not read process events: %d", code)
|
||||
}
|
||||
account, err := first.GetAccountByOwner(store.OwnerDefault)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := first.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, _ = doJSON(t, h, http.MethodGet, path, nil)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("disabled local account read process events: %d", code)
|
||||
}
|
||||
}
|
||||
+246
-71
@@ -42,26 +42,28 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
projectLifecycle *extension.ProjectLifecycle
|
||||
projectHost func(string) extension.Host
|
||||
st *store.Store
|
||||
Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入)
|
||||
Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入)
|
||||
Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入)
|
||||
Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入)
|
||||
LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入)
|
||||
mgr *runmgr.Manager
|
||||
Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入)
|
||||
Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17)
|
||||
Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27)
|
||||
Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接)
|
||||
Workflows *workflow.Registry // 已装载工作流(nil = 不接)
|
||||
Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退)
|
||||
reg *tools.Registry
|
||||
prof *profiles.Service
|
||||
set *settings.Service
|
||||
plugins *plugins.Host
|
||||
static fs.FS
|
||||
accountSearchKey store.AccountCursorKey
|
||||
accountSearchKeyErr error
|
||||
projectLifecycle *extension.ProjectLifecycle
|
||||
projectHost func(string) extension.Host
|
||||
st *store.Store
|
||||
Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入)
|
||||
Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入)
|
||||
Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入)
|
||||
Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入)
|
||||
LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入)
|
||||
mgr *runmgr.Manager
|
||||
Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入)
|
||||
Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17)
|
||||
Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27)
|
||||
Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接)
|
||||
Workflows *workflow.Registry // 已装载工作流(nil = 不接)
|
||||
Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退)
|
||||
reg *tools.Registry
|
||||
prof *profiles.Service
|
||||
set *settings.Service
|
||||
plugins *plugins.Host
|
||||
static fs.FS
|
||||
// Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。
|
||||
Subscribe func(conversationID string) (<-chan core.StreamEvent, func())
|
||||
// DisableUploadIndexing EXT-9:true 时上传端点不再自动写检索索引(原件与
|
||||
@@ -160,7 +162,9 @@ func New(st *store.Store, mgr *runmgr.Manager, reg *tools.Registry, p *profiles.
|
||||
if err != nil {
|
||||
panic(err) // embed 布局错误属构建期问题
|
||||
}
|
||||
return &Server{st: st, mgr: mgr, reg: reg, prof: p, set: s, plugins: ph, static: sub}
|
||||
key, keyErr := st.GetAccountCursorKey()
|
||||
return &Server{st: st, mgr: mgr, reg: reg, prof: p, set: s, plugins: ph, static: sub,
|
||||
accountSearchKey: key, accountSearchKeyErr: keyErr}
|
||||
}
|
||||
|
||||
// NLIntentGate 意图闸门端口(intentgate.Gate 实现;避免包依赖方向反转)。
|
||||
@@ -219,7 +223,7 @@ func (s *Server) WireBuiltinCommands() {
|
||||
ParamsSchema: json.RawMessage(`{"type":"object","required":["on"],"properties":{"on":{"type":"boolean"}}}`)},
|
||||
"/mock", func(ctx context.Context, req cmdbus.Request) (any, error) {
|
||||
on, _ := req.Params["on"].(bool)
|
||||
if err := s.set.MergeUser(req.Owner, map[string]any{"runtime": map[string]any{"mock": on}}); err != nil {
|
||||
if err := s.set.MergeUserAccountChecked(req.Owner, map[string]any{"runtime": map[string]any{"mock": on}}, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"runtime.mock": on}, nil
|
||||
@@ -228,7 +232,7 @@ func (s *Server) WireBuiltinCommands() {
|
||||
ParamsSchema: json.RawMessage(`{"type":"object","required":["model"],"properties":{"model":{"type":"string"}}}`)},
|
||||
"/model", func(ctx context.Context, req cmdbus.Request) (any, error) {
|
||||
m, _ := req.Params["model"].(string)
|
||||
if err := s.set.MergeUser(req.Owner, map[string]any{"models": map[string]any{"chat": map[string]any{"model": m}}}); err != nil {
|
||||
if err := s.set.MergeUserAccountChecked(req.Owner, map[string]any{"models": map[string]any{"chat": map[string]any{"model": m}}}, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"model": m}, nil
|
||||
@@ -237,7 +241,7 @@ func (s *Server) WireBuiltinCommands() {
|
||||
ParamsSchema: json.RawMessage(`{"type":"object","required":["profileId"],"properties":{"profileId":{"type":"string"}}}`)},
|
||||
"/profile", func(ctx context.Context, req cmdbus.Request) (any, error) {
|
||||
pid, _ := req.Params["profileId"].(string)
|
||||
if err := s.st.UpdateConversation(req.Owner, req.ConversationID, "", pid); err != nil {
|
||||
if err := s.st.UpdateConversationAccountChecked(req.Owner, req.ConversationID, "", pid, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"profile": pid}, nil
|
||||
@@ -282,10 +286,25 @@ func (s *Server) Handler() http.Handler {
|
||||
// ---- 认证与会话(管理员发放制,R2)----
|
||||
mux.HandleFunc("POST /api/auth/login", s.login)
|
||||
mux.HandleFunc("POST /api/auth/login-key/redeem", s.redeemLoginKey)
|
||||
mux.HandleFunc("POST /api/auth/recovery-grants/redeem", s.redeemLoginKey)
|
||||
mux.HandleFunc("POST /api/auth/logout", s.logout)
|
||||
mux.HandleFunc("POST /api/auth/password", s.changePassword)
|
||||
mux.HandleFunc("GET /api/admin/users", s.listAdminUsers)
|
||||
mux.HandleFunc("GET /api/admin/users/search", s.searchAdminUsers)
|
||||
mux.HandleFunc("POST /api/admin/users", s.createAdminUser)
|
||||
mux.HandleFunc("GET /api/me/profile", s.meProfile)
|
||||
mux.HandleFunc("PATCH /api/me/profile", s.patchMeProfile)
|
||||
mux.HandleFunc("GET /api/admin/users/{userId}", s.adminAccount)
|
||||
mux.HandleFunc("PATCH /api/admin/users/{userId}/profile", s.patchAdminAccountProfile)
|
||||
mux.HandleFunc("PUT /api/admin/users/{userId}/state", s.setAdminAccountState)
|
||||
mux.HandleFunc("POST /api/admin/users/{userId}/recovery-proposals", s.createAdminRecoveryProposal)
|
||||
mux.HandleFunc("GET /api/admin/users/{userId}/recovery-proposals", s.listAdminRecoveryProposals)
|
||||
mux.HandleFunc("GET /api/admin/users/{userId}/recovery-proposals/{id}", s.getAdminRecoveryProposal)
|
||||
mux.HandleFunc("DELETE /api/admin/users/{userId}/recovery-proposals/{id}", s.revokeAdminRecoveryProposal)
|
||||
mux.HandleFunc("GET /api/me/recovery-proposals", s.listMyRecoveryProposals)
|
||||
mux.HandleFunc("GET /api/me/recovery-proposals/{id}", s.getMyRecoveryProposal)
|
||||
mux.HandleFunc("POST /api/auth/recovery-proposals/{id}/confirm", s.confirmRecoveryProposal)
|
||||
mux.HandleFunc("POST /api/auth/recovery-proposals/{id}/rotate-grant", s.rotateRecoveryGrant)
|
||||
mux.HandleFunc("POST /api/admin/users/{owner}/password", s.resetAdminUserPassword)
|
||||
mux.HandleFunc("POST /api/admin/users/{owner}/login-keys", s.issueAdminLoginKey)
|
||||
mux.HandleFunc("GET /api/admin/users/{owner}/login-keys", s.listAdminLoginKeys)
|
||||
@@ -300,6 +319,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/projects/{id}/conversations", s.createConversation)
|
||||
mux.HandleFunc("GET /api/conversations", s.listTasks)
|
||||
mux.HandleFunc("GET /api/conversations/{id}", s.conversationDetail)
|
||||
mux.HandleFunc("GET /api/conversations/{id}/runs/{runId}/events", s.runProcessEvents)
|
||||
mux.HandleFunc("GET /api/conversations/{id}/export", s.exportConversation)
|
||||
mux.HandleFunc("PATCH /api/conversations/{id}", s.patchConversation)
|
||||
mux.HandleFunc("DELETE /api/conversations/{id}", s.deleteConversation)
|
||||
@@ -513,6 +533,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
lastVisible := since
|
||||
send := func(e core.StreamEvent) bool {
|
||||
// Recheck before every frame, including stored replay. A revocation during
|
||||
// an active stream must not disclose the next queued business event.
|
||||
if !stillAuthed() {
|
||||
return false
|
||||
}
|
||||
if e.Seq <= lastVisible {
|
||||
return true
|
||||
}
|
||||
@@ -572,6 +597,10 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
case <-heartbeat.C:
|
||||
if !stillAuthed() {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, ": ping\n\n")
|
||||
flusher.Flush()
|
||||
case e, ok := <-live:
|
||||
@@ -699,7 +728,7 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
|
||||
writeProjectJSON(w, s.publicProjectMetadata(p), err)
|
||||
return
|
||||
}
|
||||
p, err := s.st.CreateProject(s.owner(r), strings.TrimSpace(req.Name))
|
||||
p, err := s.st.CreateProjectAccountChecked(s.owner(r), strings.TrimSpace(req.Name), s.Auth == nil || !s.Auth.Enabled)
|
||||
writeJSONOr(w, p, err)
|
||||
}
|
||||
|
||||
@@ -740,7 +769,7 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if req.Settings != nil {
|
||||
if err := s.st.UpdateProjectSettings(s.owner(r), id, req.Settings); err != nil {
|
||||
if err := s.st.UpdateProjectSettingsAccountChecked(s.owner(r), id, req.Settings, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -766,13 +795,13 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if req.Name != nil {
|
||||
if err := s.st.RenameProject(s.owner(r), id, *req.Name); err != nil {
|
||||
if err := s.st.RenameProjectAccountChecked(s.owner(r), id, *req.Name, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Settings != nil {
|
||||
if err := s.st.UpdateProjectSettings(s.owner(r), id, req.Settings); err != nil {
|
||||
if err := s.st.UpdateProjectSettingsAccountChecked(s.owner(r), id, req.Settings, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -794,7 +823,7 @@ func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
writeOKOr(w, s.st.DeleteProject(s.owner(r), r.PathValue("id")))
|
||||
writeOKOr(w, s.st.DeleteProjectAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -831,6 +860,11 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
||||
if sc := SessionScope(r); sc != "" {
|
||||
out["sessionScope"] = sc
|
||||
}
|
||||
if AuthVia(r) == AuthViaSession {
|
||||
if mode, err := s.st.SessionPasswordChangeMode(sessionToken(r)); err == nil {
|
||||
out["passwordChangeMode"] = mode
|
||||
}
|
||||
}
|
||||
if st, err := s.st.GetUserAuthState(owner); err == nil {
|
||||
out["isAdmin"] = st.IsAdmin
|
||||
out["mustChangePassword"] = st.MustChangePassword
|
||||
@@ -878,6 +912,58 @@ func (s *Server) conversationDetail(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// runProcessEvents lets an owner reopen the exact persisted process after the
|
||||
// transient SSE bubble has been replaced by the settled assistant message.
|
||||
func (s *Server) runProcessEvents(w http.ResponseWriter, r *http.Request) {
|
||||
id, runID := r.PathValue("id"), r.PathValue("runId")
|
||||
owner := s.owner(r)
|
||||
if err := s.checkActorBeforeExternalEffect(owner); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if _, err := s.st.GetConversation(owner, id); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
run, err := s.st.GetRun(owner, runID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if string(run.ConversationID) != id {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
after, err := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64)
|
||||
if r.URL.Query().Get("after") == "" {
|
||||
after, err = 0, nil
|
||||
}
|
||||
if err != nil || after < 0 {
|
||||
http.Error(w, "invalid after", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
limit := 500
|
||||
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||
limit, err = strconv.Atoi(raw)
|
||||
if err != nil || limit < 1 || limit > 500 {
|
||||
http.Error(w, "invalid limit", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
events, err := s.st.RunEventsAfter(id, runID, after, limit)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
// The authenticated account can be disabled while stored events are read.
|
||||
// Recheck immediately before returning private process output.
|
||||
if err := s.checkActorBeforeExternalEffect(owner); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"events": events})
|
||||
}
|
||||
|
||||
func (s *Server) patchConversation(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var req struct {
|
||||
@@ -894,28 +980,28 @@ func (s *Server) patchConversation(w http.ResponseWriter, r *http.Request) {
|
||||
onlyMode := req.PermissionMode != nil && req.Title == "" && req.ProfileID == "" && req.Pinned == nil && req.Archived == nil && req.AllowCrossRead == nil && req.CrossPrivate == nil && req.SessionNote == nil
|
||||
onlyNote := req.SessionNote != nil && req.Title == "" && req.ProfileID == "" && req.Pinned == nil && req.Archived == nil && req.AllowCrossRead == nil && req.CrossPrivate == nil && req.PermissionMode == nil
|
||||
if !onlyMode && !onlyNote {
|
||||
if err := s.st.UpdateConversation(s.owner(r), id, req.Title, req.ProfileID); err != nil {
|
||||
if err := s.st.UpdateConversationAccountChecked(s.owner(r), id, req.Title, req.ProfileID, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.PermissionMode != nil {
|
||||
if err := s.st.SetPermissionMode(s.owner(r), id, *req.PermissionMode); err != nil {
|
||||
if err := s.st.SetPermissionModeAccountChecked(s.owner(r), id, *req.PermissionMode, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.SessionNote != nil {
|
||||
if err := s.st.SetSessionNote(s.owner(r), id, *req.SessionNote); err != nil {
|
||||
if err := s.st.SetSessionNoteAccountChecked(s.owner(r), id, *req.SessionNote, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeOKOr(w, s.st.SetConversationFlags(s.owner(r), id, req.Pinned, req.Archived, req.AllowCrossRead, req.CrossPrivate))
|
||||
writeOKOr(w, s.st.SetConversationFlagsAccountChecked(s.owner(r), id, req.Pinned, req.Archived, req.AllowCrossRead, req.CrossPrivate, s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
func (s *Server) deleteConversation(w http.ResponseWriter, r *http.Request) {
|
||||
writeOKOr(w, s.st.DeleteConversation(s.owner(r), r.PathValue("id")))
|
||||
writeOKOr(w, s.st.DeleteConversationAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
// ---- 消息发送(含命令路由与附件) ----
|
||||
@@ -1035,14 +1121,14 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "execution queue full(runtime.execQueueMax)", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
if err := s.mgr.ClaimConversation(convID); err != nil {
|
||||
if err := s.mgr.ClaimConversation(convID, owner); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
claimed = true
|
||||
}
|
||||
msg := core.Message{ConversationID: convID, Role: core.RoleUser, Parts: parts}
|
||||
if err := s.st.AppendMessageWithRequest(&msg, req.RequestID); err != nil {
|
||||
if err := s.st.AppendMessageWithRequestAccountChecked(owner, s.Auth == nil || !s.Auth.Enabled, &msg, req.RequestID); err != nil {
|
||||
var dup *store.DuplicateRequestError
|
||||
if errors.As(err, &dup) {
|
||||
if claimed {
|
||||
@@ -1090,7 +1176,11 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
writeProjectErr(w, startErr)
|
||||
return
|
||||
}
|
||||
_ = s.st.SetMessageRunID(msg.ID, run.ID)
|
||||
if err := s.st.SetMessageRunIDAccountChecked(owner, msg.ID, string(run.ID), s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
_ = s.mgr.CancelRun(string(run.ID))
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
msg.RunID = run.ID
|
||||
writeJSON(w, http.StatusOK, map[string]any{"message": msg, "run": run})
|
||||
}
|
||||
@@ -1251,12 +1341,8 @@ func (s *Server) cancelRun(w http.ResponseWriter, r *http.Request) {
|
||||
// conversation_id 必须回填真实值:消费侧按执行权 JOIN 匹配,空串永远
|
||||
// 无法送达(同批实测)。
|
||||
if s.st.Dialect() == "postgres" && !s.mgr.RunActiveLocally(runID) {
|
||||
convID := ""
|
||||
if run, gerr := s.st.GetRun(s.owner(r), runID); gerr == nil {
|
||||
convID = string(run.ConversationID)
|
||||
}
|
||||
if cerr := s.st.EnqueueCommand(store.NewID("cmd"), "cancel", runID, convID, 0,
|
||||
runmgr.CancelCommandPayload{Owner: s.owner(r), RunID: runID}); cerr != nil {
|
||||
if cerr := s.st.EnqueueCommandAccountChecked(s.owner(r), store.NewID("cmd"), "cancel", runID, 0,
|
||||
runmgr.CancelCommandPayload{Owner: s.owner(r), RunID: runID}, s.Auth == nil || !s.Auth.Enabled); cerr != nil {
|
||||
writeStoreErr(w, cerr)
|
||||
return
|
||||
}
|
||||
@@ -1310,8 +1396,8 @@ func (s *Server) resolveHITL(w http.ResponseWriter, r *http.Request) {
|
||||
if kind == "" {
|
||||
kind = "free"
|
||||
}
|
||||
if cerr := s.st.EnqueueCommand(store.NewID("cmd"), "hitl", string(card.RunID), string(card.ConversationID), 0,
|
||||
runmgr.HITLCommandPayload{Owner: s.owner(r), CardID: string(resp.CardID), Kind: kind, Answer: json.RawMessage(resp.FreeText)}); cerr == nil {
|
||||
if cerr := s.st.EnqueueCommandAccountChecked(s.owner(r), store.NewID("cmd"), "hitl", string(card.RunID), 0,
|
||||
runmgr.HITLCommandPayload{Owner: s.owner(r), CardID: string(resp.CardID), Kind: kind, Answer: json.RawMessage(resp.FreeText)}, s.Auth == nil || !s.Auth.Enabled); cerr == nil {
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"accepted": true, "via": "command"})
|
||||
return
|
||||
}
|
||||
@@ -1351,7 +1437,7 @@ func (s *Server) listProjectCards(w http.ResponseWriter, r *http.Request) {
|
||||
// cardToProject 会话卡留档到项目收件箱(D48):复制为项目级卡(幂等),原卡
|
||||
// dismissed 留痕;回执给前端 upsert 两张卡的终态。
|
||||
func (s *Server) cardToProject(w http.ResponseWriter, r *http.Request) {
|
||||
created, err := s.Cards.ToProject(s.owner(r), r.PathValue("id"))
|
||||
created, err := s.Cards.ToProjectAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -1396,12 +1482,12 @@ func (s *Server) cardAction(w http.ResponseWriter, r *http.Request) {
|
||||
// 命令确认卡(ref=cmd:<id>)走总线回道:confirm=执行挂起命令,其余=取消。
|
||||
if ref, err := s.st.CardRef(owner, r.PathValue("id")); err == nil && strings.HasPrefix(ref, "cmd:") {
|
||||
if card, err := s.Cards.Get(owner, r.PathValue("id")); err == nil && card.Status == core.CardActionRequired {
|
||||
resp := s.Bus.Confirm(owner, card.ID, r.PathValue("actionId"))
|
||||
resp := s.Bus.ConfirmContext(r.Context(), owner, card.ID, r.PathValue("actionId"))
|
||||
writeJSON(w, http.StatusOK, map[string]any{"card": card, "receipt": resp.Receipt, "payload": resp.Payload})
|
||||
return
|
||||
}
|
||||
}
|
||||
card, label, err := s.Cards.Action(owner, r.PathValue("id"), r.PathValue("actionId"), body.FreeText)
|
||||
card, label, err := s.Cards.ActionAccountChecked(owner, r.PathValue("id"), r.PathValue("actionId"), body.FreeText, s.Auth == nil || !s.Auth.Enabled)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -1412,7 +1498,10 @@ func (s *Server) cardAction(w http.ResponseWriter, r *http.Request) {
|
||||
if card.ConversationID != "" {
|
||||
receipt = &core.Message{ID: store.NewID("msg"), ConversationID: card.ConversationID, Role: core.RoleAssistant, CreatedAt: time.Now().UTC()}
|
||||
receipt.Parts = []core.Part{{Type: core.PartText, Text: fmt.Sprintf("✓ 已按「%s」处理「%s」——结论已记录并留痕(card:%s)", label, card.Title, card.ID)}}
|
||||
_ = s.st.AppendMessage(receipt)
|
||||
if err := s.st.AppendMessageWithRequestAccountChecked(owner, s.Auth == nil || !s.Auth.Enabled, receipt, ""); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"card": card, "receipt": receipt})
|
||||
}
|
||||
@@ -1422,12 +1511,12 @@ func (s *Server) cardPin(w http.ResponseWriter, r *http.Request) {
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
card, err := s.Cards.SetPinned(s.owner(r), r.PathValue("id"), body.Pinned)
|
||||
card, err := s.Cards.SetPinnedAccountChecked(s.owner(r), r.PathValue("id"), body.Pinned, s.Auth == nil || !s.Auth.Enabled)
|
||||
writeJSONOr(w, map[string]any{"card": card}, err)
|
||||
}
|
||||
|
||||
func (s *Server) cardDismiss(w http.ResponseWriter, r *http.Request) {
|
||||
card, err := s.Cards.Dismiss(s.owner(r), r.PathValue("id"))
|
||||
card, err := s.Cards.DismissAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled)
|
||||
writeJSONOr(w, map[string]any{"card": card}, err)
|
||||
}
|
||||
|
||||
@@ -1473,7 +1562,7 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
mime := header.Header.Get("Content-Type")
|
||||
art := core.Artifact{ProjectID: projectID, Owner: owner, Name: header.Filename, Kind: kindOf(header.Filename), MIME: mime}
|
||||
if err := s.st.CreateArtifact(&art, content); err != nil {
|
||||
if err := s.st.CreateArtifactAccountChecked(owner, &art, content, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -1488,7 +1577,13 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
|
||||
// original filename/parser metadata out of shared project cards and events.
|
||||
showIngestCard := s.Cards != nil && s.projectLifecycle == nil
|
||||
if showIngestCard {
|
||||
ingestCard, _ = s.Cards.Create(owner, ingestCard, "")
|
||||
var cardErr error
|
||||
ingestCard, cardErr = s.Cards.CreateAccountChecked(owner, ingestCard, "", s.Auth == nil || !s.Auth.Enabled)
|
||||
if errors.Is(cardErr, store.ErrAccountForbidden) {
|
||||
writeStoreErr(w, cardErr)
|
||||
return
|
||||
}
|
||||
showIngestCard = cardErr == nil
|
||||
}
|
||||
doc := ingest.Parse(art.Name, mime, content)
|
||||
if showIngestCard {
|
||||
@@ -1507,19 +1602,42 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
|
||||
body = append(body, core.UINode{Type: core.UIMarkdown, Markdown: "_解析不完整不静默:原始文件已永久保留,可取回或重试。_"})
|
||||
}
|
||||
ingestCard.Body = body
|
||||
_ = s.Cards.Update(owner, ingestCard)
|
||||
if _, version, err := s.st.GetCardVersion(owner, ingestCard.ID); err == nil {
|
||||
if err := s.Cards.UpdateVersionedAccountChecked(owner, ingestCard, version, s.Auth == nil || !s.Auth.Enabled); errors.Is(err, store.ErrAccountForbidden) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// 文本可解析的进检索索引(管线分块+全文+可选向量)。
|
||||
// EXT-9:宿主关闭自动索引时跳过(发布决定移交下游命令层;原件与摄取卡不变)。
|
||||
if !s.DisableUploadIndexing {
|
||||
if plain := doc.PlainText(); plain != "" && len(plain) < 200000 {
|
||||
var indexErr error
|
||||
if s.Indexer != nil {
|
||||
_ = s.Indexer.IndexDocument(owner, projectID, "upload", art.Name, art.ID, plain)
|
||||
if checked, ok := s.Indexer.(interface {
|
||||
IndexDocumentAccountChecked(context.Context, core.ID, core.ID, string, string, string, string, bool) error
|
||||
}); ok {
|
||||
indexErr = checked.IndexDocumentAccountChecked(r.Context(), core.ID(owner), core.ID(projectID), "upload", art.Name, art.ID, plain, s.Auth == nil || !s.Auth.Enabled)
|
||||
} else if s.Auth != nil && s.Auth.Enabled {
|
||||
indexErr = store.ErrAccountForbidden // Unknown indexer has no final account fence.
|
||||
} else {
|
||||
indexErr = s.Indexer.IndexDocument(owner, projectID, "upload", art.Name, art.ID, plain)
|
||||
}
|
||||
} else {
|
||||
_ = s.st.AddKnowledge(core.KnowledgeDoc{Owner: owner, ProjectID: projectID, Source: "upload", Title: art.Name, Ref: art.ID, Chunk: plain})
|
||||
indexErr = s.st.AddKnowledgeAccountChecked(owner, s.Auth == nil || !s.Auth.Enabled,
|
||||
core.KnowledgeDoc{Owner: core.ID(owner), ProjectID: core.ID(projectID), Source: "upload", Title: art.Name, Ref: art.ID, Chunk: plain})
|
||||
}
|
||||
if errors.Is(indexErr, store.ErrAccountForbidden) {
|
||||
writeStoreErr(w, indexErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.checkActorBeforeExternalEffect(owner); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, core.UploadInfo{ArtifactID: art.ID, Filename: art.Name, MIME: mime, Size: art.Size, Parsed: &doc})
|
||||
}
|
||||
|
||||
@@ -1617,11 +1735,11 @@ func (s *Server) artifactContent(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) putArtifactContent(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(io.LimitReader(r.Body, 20<<20))
|
||||
writeOKOr(w, s.st.UpdateArtifactContent(s.owner(r), r.PathValue("id"), body))
|
||||
writeOKOr(w, s.st.UpdateArtifactContentAccountChecked(s.owner(r), r.PathValue("id"), body, s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
func (s *Server) deleteArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
writeOKOr(w, s.st.DeleteArtifact(s.owner(r), r.PathValue("id")))
|
||||
writeOKOr(w, s.st.DeleteArtifactAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
// ---- 画像 ----
|
||||
@@ -1640,15 +1758,19 @@ func (s *Server) saveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if p.ID == "" {
|
||||
p.ID = "prof_" + strconv.FormatInt(time.Now().UnixNano()%100000, 10)
|
||||
}
|
||||
if err := s.prof.Save(s.owner(r), p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
if err := s.prof.SaveAccountChecked(s.owner(r), p, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
if errors.Is(err, store.ErrAccountForbidden) {
|
||||
writeStoreErr(w, err)
|
||||
} else {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (s *Server) deleteProfile(w http.ResponseWriter, r *http.Request) {
|
||||
writeOKOr(w, s.prof.Delete(s.owner(r), r.PathValue("id")))
|
||||
writeOKOr(w, s.prof.DeleteAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
// ---- 设置 ----
|
||||
@@ -1687,7 +1809,9 @@ func (s *Server) putSettingsUser(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.writeSettingsMerge(w, func() error { return s.set.MergeUser(s.owner(r), values) })
|
||||
s.writeSettingsMerge(w, func() error {
|
||||
return s.set.MergeUserAccountChecked(s.owner(r), values, s.Auth == nil || !s.Auth.Enabled)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1697,7 +1821,7 @@ func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.writeSettingsMerge(w, func() error {
|
||||
return s.set.MergeProject(s.owner(r), r.PathValue("id"), values)
|
||||
return s.set.MergeProjectAccountChecked(s.owner(r), r.PathValue("id"), values, s.Auth == nil || !s.Auth.Enabled)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1723,7 +1847,7 @@ func (s *Server) writeSettingsMerge(w http.ResponseWriter, merge func() error) {
|
||||
// (Auth 未启用)本机即管理员——该档全部 API 本就无认证,锁系统层只会破坏
|
||||
// 嵌入式部署的配置播种,不增加安全边界。
|
||||
func (s *Server) requireSystemAdmin(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
if s.Auth == nil {
|
||||
if s.Auth == nil || !s.Auth.Enabled {
|
||||
return s.owner(r), true
|
||||
}
|
||||
return s.requireAdmin(w, r)
|
||||
@@ -1747,7 +1871,8 @@ func (s *Server) getSettingsSystem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) putSettingsSystem(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
||||
actor, ok := s.requireSystemAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var values map[string]any
|
||||
@@ -1755,7 +1880,7 @@ func (s *Server) putSettingsSystem(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeOKOr(w, s.set.MergeSystem(values))
|
||||
writeOKOr(w, s.set.MergeSystemAccountChecked(actor, values, s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
// ---- 工具(用户声明式 HTTP 工具注册)----
|
||||
@@ -1791,6 +1916,10 @@ func (s *Server) createUserTool(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
lookup := func(key string) string { return s.set.Effective(s.owner(r), "").GetString(key, "") }
|
||||
if err := s.checkActorBeforeExternalEffect(s.owner(r)); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.reg.Register(desc, tools.HTTPExecutor(*desc.HTTP, lookup)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -1804,6 +1933,10 @@ func (s *Server) deleteUserTool(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "仅可删除 user 来源工具", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := s.checkActorBeforeExternalEffect(s.owner(r)); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.reg.UnregisterBySource(core.SourceUser, name)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": name})
|
||||
}
|
||||
@@ -1873,7 +2006,19 @@ func (s *Server) listPlugins(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) reloadPlugins(w http.ResponseWriter, r *http.Request) {
|
||||
results := s.plugins.LoadDir()
|
||||
actor, ok := s.requireSystemAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.checkActorBeforeExternalEffect(actor); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
results, err := s.plugins.LoadDirAccountChecked(actor, s.Auth == nil || !s.Auth.Enabled)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"reloaded": len(results), "errors": results})
|
||||
}
|
||||
|
||||
@@ -2001,8 +2146,12 @@ func (s *Server) savePromptModule(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "id/layer/body required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.Prompts.Save(s.owner(r), m); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
if err := s.Prompts.SaveAccountChecked(s.owner(r), m, s.Auth == nil || !s.Auth.Enabled); err != nil {
|
||||
if errors.Is(err, store.ErrAccountForbidden) {
|
||||
writeStoreErr(w, err)
|
||||
} else {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, m)
|
||||
@@ -2013,7 +2162,7 @@ func (s *Server) deletePromptModule(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "prompt library not enabled", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSONOr(w, map[string]any{"deleted": r.PathValue("id")}, s.Prompts.Delete(s.owner(r), r.PathValue("id")))
|
||||
writeJSONOr(w, map[string]any{"deleted": r.PathValue("id")}, s.Prompts.DeleteAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
func (s *Server) listParsers(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, ingest.ParsersDescription())
|
||||
@@ -2037,7 +2186,7 @@ func (s *Server) addKnowledge(w http.ResponseWriter, r *http.Request) {
|
||||
docs[i].Source = "manual"
|
||||
}
|
||||
}
|
||||
writeOKOr(w, s.st.AddKnowledge(docs...))
|
||||
writeOKOr(w, s.st.AddKnowledgeForOwnedProjectAccountChecked(owner, r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled, docs...))
|
||||
}
|
||||
|
||||
func (s *Server) searchKnowledge(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -2152,6 +2301,18 @@ func writeOKOr(w http.ResponseWriter, err error) {
|
||||
}
|
||||
|
||||
func writeStoreErr(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, store.ErrOwnerProofUnavailable) {
|
||||
http.Error(w, "OWNER_PROOF_UNAVAILABLE", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrAccountForbidden) {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrInvalidAccountProfile) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
@@ -2177,12 +2338,26 @@ func writeStoreErr(w http.ResponseWriter, err error) {
|
||||
|
||||
// backup 触发一致性在线备份(嵌入式档:VACUUM INTO;PG 档见运维指南 pg_dump)。
|
||||
func (s *Server) backup(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Auth != nil && s.Auth.Enabled {
|
||||
// A database backup includes other owners' business content. IAM U3
|
||||
// does not grant a system administrator that content authority.
|
||||
http.Error(w, "backup requires local owner maintenance", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := s.checkActorBeforeExternalEffect(s.owner(r)); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
dir := filepath.Join(s.st.DataDir(), "backups")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
dest := filepath.Join(dir, "agent-system-"+time.Now().UTC().Format("20060102T150405Z")+".db")
|
||||
if err := s.checkActorBeforeExternalEffect(s.owner(r)); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.st.Backup(dest); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotImplemented)
|
||||
return
|
||||
|
||||
@@ -16,11 +16,11 @@ import (
|
||||
func TestSharedProjectListAndConversation(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
alice, err := st.CreateAPIKey("alice", "e2e")
|
||||
alice, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bob, err := st.CreateAPIKey("bob", "e2e")
|
||||
bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -19,11 +19,11 @@ import (
|
||||
func TestSharedKnowledgeRetrieval(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
alice, err := st.CreateAPIKey("alice", "e2e")
|
||||
alice, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bob, err := st.CreateAPIKey("bob", "e2e")
|
||||
bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -94,11 +94,11 @@ func TestProjectSSEScopeGate(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
_ = st
|
||||
h := srv.Handler()
|
||||
alice, err := st.CreateAPIKey("alice", "e2e")
|
||||
alice, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bob, err := st.CreateAPIKey("bob", "e2e")
|
||||
bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -147,11 +147,11 @@ func TestProjectSSEScopeGate(t *testing.T) {
|
||||
func TestSharedCitationRefDownloadable(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
alice, err := st.CreateAPIKey("alice", "e2e")
|
||||
alice, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bob, err := st.CreateAPIKey("bob", "e2e")
|
||||
bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
func TestSurfaceDirectAPIGuards(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
key, err := st.CreateAPIKey("alice", "e2e")
|
||||
key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestSurfaceDirectAPIGuards(t *testing.T) {
|
||||
func TestSurfaceActionEndpoint(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
key, _ := st.CreateAPIKey("alice", "e2e")
|
||||
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||||
svc := surfacesvc.New(st, nil)
|
||||
svc.SetDispatcher(func(ctx context.Context, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) {
|
||||
return surfacesvc.ActionOutcome{State: "committed", Receipt: map[string]any{"ok": true}}, nil
|
||||
|
||||
@@ -79,6 +79,24 @@ func (s *Service) snapshotThrottled(owner, scope string) {
|
||||
_, _ = s.st.SaveSnapshotIfChanged(owner, scope, string(raw), "save")
|
||||
}
|
||||
|
||||
// snapshotThrottledAccountChecked keeps the best-effort snapshot behavior of
|
||||
// settings writes while fencing its separate final insert transaction. A
|
||||
// disable between the settings commit and this snapshot can omit the snapshot.
|
||||
func (s *Service) snapshotThrottledAccountChecked(actor, owner, scope string, allowImplicitDefault bool) {
|
||||
if at, ok := s.st.LatestSnapshotAt(owner, scope); ok && time.Since(at) < 10*time.Minute {
|
||||
return
|
||||
}
|
||||
cur, err := s.st.GetSettingsScope(owner, scope)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(cur)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = s.st.SaveSnapshotIfChangedAccountChecked(actor, owner, scope, string(raw), "save", allowImplicitDefault)
|
||||
}
|
||||
|
||||
func lookupString(m map[string]any, path string) (string, bool) {
|
||||
var cur any = m
|
||||
for _, p := range strings.Split(path, ".") {
|
||||
|
||||
@@ -233,6 +233,39 @@ func (s *Service) MergeSystem(values map[string]any) error {
|
||||
return s.mergeScope(OwnerSystemRow, ScopeSystem, values)
|
||||
}
|
||||
|
||||
// MergeUserAccountChecked and MergeProjectAccountChecked are request-facing
|
||||
// writes. Their final Store transaction locks and checks the actor before the
|
||||
// scope lock and write; startup migration keeps using the legacy methods.
|
||||
func (s *Service) MergeUserAccountChecked(actor string, values map[string]any, allowImplicitDefault bool) error {
|
||||
return s.mergeScopeAccountChecked(actor, actor, ScopeUser, values, allowImplicitDefault)
|
||||
}
|
||||
|
||||
func (s *Service) MergeProjectAccountChecked(actor, projectID string, values map[string]any, allowImplicitDefault bool) error {
|
||||
return s.mergeScopeAccountChecked(actor, actor, ScopeProjectPrefix+projectID, values, allowImplicitDefault)
|
||||
}
|
||||
|
||||
// MergeSystemAccountChecked persists under the synthetic system owner while
|
||||
// checking the real request actor's enabled state and administrator role.
|
||||
func (s *Service) MergeSystemAccountChecked(actor string, values map[string]any, allowImplicitDefault bool) error {
|
||||
return s.mergeScopeAccountChecked(actor, OwnerSystemRow, ScopeSystem, values, allowImplicitDefault)
|
||||
}
|
||||
|
||||
func (s *Service) mergeScopeAccountChecked(actor, owner, scope string, values map[string]any, allowImplicitDefault bool) error {
|
||||
incoming := prepareWrite(values)
|
||||
if scope != ScopeSystem {
|
||||
if err := s.checkManagedWrite(incoming); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.st.UpdateSettingsScopeAccountChecked(actor, owner, scope, allowImplicitDefault, func(cur map[string]any) (map[string]any, error) {
|
||||
return core.MergeSettings(normalizeKeys(cur), incoming), nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
s.snapshotThrottledAccountChecked(actor, owner, scope, allowImplicitDefault)
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeScope 先把库里和本次写入的点路径键展开成嵌套,再深合并。
|
||||
// 租户层(user/project)触碰受管路径时整笔拒绝。掩码占位符视为未修改,不覆盖已存密钥。
|
||||
func (s *Service) mergeScope(owner, scope string, values map[string]any) error {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func TestGuardedSettingsMergePreservesManagedPathsAndEffectiveState(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "settings.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
svc := NewService(st)
|
||||
for _, a := range []struct {
|
||||
owner string
|
||||
admin bool
|
||||
}{{"admin", true}, {"alice", false}} {
|
||||
if err := st.CreateAccount(a.owner, "", "", a.admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
project, err := st.CreateProject("alice", "owned")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.MergeUserAccountChecked("alice", map[string]any{"models.chat.apiKey": "forbidden"}, false); err == nil {
|
||||
t.Fatal("guarded user merge accepted managed path")
|
||||
} else {
|
||||
var managed *ErrManagedPath
|
||||
if !errors.As(err, &managed) {
|
||||
t.Fatalf("managed path error lost: %v", err)
|
||||
}
|
||||
}
|
||||
if err := svc.MergeUserAccountChecked("alice", map[string]any{"ui.theme": "light"}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.MergeProjectAccountChecked("alice", project.ID, map[string]any{"context.maxHistoryMessages": 12}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.MergeSystemAccountChecked("alice", map[string]any{"models.chat.apiKey": "forbidden"}, false); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("non-admin wrote system settings: %v", err)
|
||||
}
|
||||
if err := svc.MergeSystemAccountChecked("admin", map[string]any{"models.chat.apiKey": "system-secret"}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := svc.Effective("alice", project.ID); got.GetString("models.chat.apiKey", "") != "system-secret" || got.GetString("ui.theme", "") != "light" {
|
||||
t.Fatalf("guarded merges did not produce effective settings: %#v", got)
|
||||
}
|
||||
for _, scope := range []struct{ owner, name string }{{"alice", ScopeUser}, {"alice", ScopeProjectPrefix + project.ID}, {OwnerSystemRow, ScopeSystem}} {
|
||||
var count int
|
||||
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM settings_snapshots WHERE owner=? AND scope=?`, scope.owner, scope.name).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("guarded merge snapshot %s/%s count=%d err=%v", scope.owner, scope.name, count, err)
|
||||
}
|
||||
}
|
||||
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.MergeUserAccountChecked("alice", map[string]any{"ui.theme": "dark"}, false); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled user merge admitted: %v", err)
|
||||
}
|
||||
if err := svc.MergeProjectAccountChecked("alice", project.ID, map[string]any{"context.maxHistoryMessages": 99}, false); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled project merge admitted: %v", err)
|
||||
}
|
||||
if got := svc.Effective("alice", project.ID); got.GetString("ui.theme", "") != "light" {
|
||||
t.Fatalf("disabled merge changed effective settings: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardedSystemMergeAllowsImplicitLocalAndRejectsExplicitDisable(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "settings.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
svc := NewService(st)
|
||||
if err := svc.MergeSystemAccountChecked(store.OwnerDefault, map[string]any{"runtime.mock": true}, false); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("implicit local system write admitted without allowance: %v", err)
|
||||
}
|
||||
if err := svc.MergeSystemAccountChecked(store.OwnerDefault, map[string]any{"runtime.mock": true}, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("admin", "", "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.EnsureUser(store.OwnerDefault, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account, err := st.GetAccountByOwner(store.OwnerDefault)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.MergeSystemAccountChecked(store.OwnerDefault, map[string]any{"runtime.mock": false}, true); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled local system write admitted: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Anthropic, PBC.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
name: canvas-design
|
||||
description: Create beautiful visual art in .png and .pdf documents using design philosophy. You should use this skill when the user asks to create a poster, piece of art, design, or other static piece. Create original visual designs, never copying existing artists' work to avoid copyright violations.
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
These are instructions for creating design philosophies - aesthetic movements that are then EXPRESSED VISUALLY. Output only .md files, .pdf files, and .png files.
|
||||
|
||||
Complete this in two steps:
|
||||
1. Design Philosophy Creation (.md file)
|
||||
2. Express by creating it on a canvas (.pdf file or .png file)
|
||||
|
||||
First, undertake this task:
|
||||
|
||||
## DESIGN PHILOSOPHY CREATION
|
||||
|
||||
To begin, create a VISUAL PHILOSOPHY (not layouts or templates) that will be interpreted through:
|
||||
- Form, space, color, composition
|
||||
- Images, graphics, shapes, patterns
|
||||
- Minimal text as visual accent
|
||||
|
||||
### THE CRITICAL UNDERSTANDING
|
||||
- What is received: Some subtle input or instructions by the user that should be taken into account, but used as a foundation; it should not constrain creative freedom.
|
||||
- What is created: A design philosophy/aesthetic movement.
|
||||
- What happens next: Then, the same version receives the philosophy and EXPRESSES IT VISUALLY - creating artifacts that are 90% visual design, 10% essential text.
|
||||
|
||||
Consider this approach:
|
||||
- Write a manifesto for an art movement
|
||||
- The next phase involves making the artwork
|
||||
|
||||
The philosophy must emphasize: Visual expression. Spatial communication. Artistic interpretation. Minimal words.
|
||||
|
||||
### HOW TO GENERATE A VISUAL PHILOSOPHY
|
||||
|
||||
**Name the movement** (1-2 words): "Brutalist Joy" / "Chromatic Silence" / "Metabolist Dreams"
|
||||
|
||||
**Articulate the philosophy** (4-6 paragraphs - concise but complete):
|
||||
|
||||
To capture the VISUAL essence, express how the philosophy manifests through:
|
||||
- Space and form
|
||||
- Color and material
|
||||
- Scale and rhythm
|
||||
- Composition and balance
|
||||
- Visual hierarchy
|
||||
|
||||
**CRITICAL GUIDELINES:**
|
||||
- **Avoid redundancy**: Each design aspect should be mentioned once. Avoid repeating points about color theory, spatial relationships, or typographic principles unless adding new depth.
|
||||
- **Emphasize craftsmanship REPEATEDLY**: The philosophy MUST stress multiple times that the final work should appear as though it took countless hours to create, was labored over with care, and comes from someone at the absolute top of their field. This framing is essential - repeat phrases like "meticulously crafted," "the product of deep expertise," "painstaking attention," "master-level execution."
|
||||
- **Leave creative space**: Remain specific about the aesthetic direction, but concise enough that the next Claude has room to make interpretive choices also at a extremely high level of craftmanship.
|
||||
|
||||
The philosophy must guide the next version to express ideas VISUALLY, not through text. Information lives in design, not paragraphs.
|
||||
|
||||
### PHILOSOPHY EXAMPLES
|
||||
|
||||
**"Concrete Poetry"**
|
||||
Philosophy: Communication through monumental form and bold geometry.
|
||||
Visual expression: Massive color blocks, sculptural typography (huge single words, tiny labels), Brutalist spatial divisions, Polish poster energy meets Le Corbusier. Ideas expressed through visual weight and spatial tension, not explanation. Text as rare, powerful gesture - never paragraphs, only essential words integrated into the visual architecture. Every element placed with the precision of a master craftsman.
|
||||
|
||||
**"Chromatic Language"**
|
||||
Philosophy: Color as the primary information system.
|
||||
Visual expression: Geometric precision where color zones create meaning. Typography minimal - small sans-serif labels letting chromatic fields communicate. Think Josef Albers' interaction meets data visualization. Information encoded spatially and chromatically. Words only to anchor what color already shows. The result of painstaking chromatic calibration.
|
||||
|
||||
**"Analog Meditation"**
|
||||
Philosophy: Quiet visual contemplation through texture and breathing room.
|
||||
Visual expression: Paper grain, ink bleeds, vast negative space. Photography and illustration dominate. Typography whispered (small, restrained, serving the visual). Japanese photobook aesthetic. Images breathe across pages. Text appears sparingly - short phrases, never explanatory blocks. Each composition balanced with the care of a meditation practice.
|
||||
|
||||
**"Organic Systems"**
|
||||
Philosophy: Natural clustering and modular growth patterns.
|
||||
Visual expression: Rounded forms, organic arrangements, color from nature through architecture. Information shown through visual diagrams, spatial relationships, iconography. Text only for key labels floating in space. The composition tells the story through expert spatial orchestration.
|
||||
|
||||
**"Geometric Silence"**
|
||||
Philosophy: Pure order and restraint.
|
||||
Visual expression: Grid-based precision, bold photography or stark graphics, dramatic negative space. Typography precise but minimal - small essential text, large quiet zones. Swiss formalism meets Brutalist material honesty. Structure communicates, not words. Every alignment the work of countless refinements.
|
||||
|
||||
*These are condensed examples. The actual design philosophy should be 4-6 substantial paragraphs.*
|
||||
|
||||
### ESSENTIAL PRINCIPLES
|
||||
- **VISUAL PHILOSOPHY**: Create an aesthetic worldview to be expressed through design
|
||||
- **MINIMAL TEXT**: Always emphasize that text is sparse, essential-only, integrated as visual element - never lengthy
|
||||
- **SPATIAL EXPRESSION**: Ideas communicate through space, form, color, composition - not paragraphs
|
||||
- **ARTISTIC FREEDOM**: The next Claude interprets the philosophy visually - provide creative room
|
||||
- **PURE DESIGN**: This is about making ART OBJECTS, not documents with decoration
|
||||
- **EXPERT CRAFTSMANSHIP**: Repeatedly emphasize the final work must look meticulously crafted, labored over with care, the product of countless hours by someone at the top of their field
|
||||
|
||||
**The design philosophy should be 4-6 paragraphs long.** Fill it with poetic design philosophy that brings together the core vision. Avoid repeating the same points. Keep the design philosophy generic without mentioning the intention of the art, as if it can be used wherever. Output the design philosophy as a .md file.
|
||||
|
||||
---
|
||||
|
||||
## DEDUCING THE SUBTLE REFERENCE
|
||||
|
||||
**CRITICAL STEP**: Before creating the canvas, identify the subtle conceptual thread from the original request.
|
||||
|
||||
**THE ESSENTIAL PRINCIPLE**:
|
||||
The topic is a **subtle, niche reference embedded within the art itself** - not always literal, always sophisticated. Someone familiar with the subject should feel it intuitively, while others simply experience a masterful abstract composition. The design philosophy provides the aesthetic language. The deduced topic provides the soul - the quiet conceptual DNA woven invisibly into form, color, and composition.
|
||||
|
||||
This is **VERY IMPORTANT**: The reference must be refined so it enhances the work's depth without announcing itself. Think like a jazz musician quoting another song - only those who know will catch it, but everyone appreciates the music.
|
||||
|
||||
---
|
||||
|
||||
## CANVAS CREATION
|
||||
|
||||
With both the philosophy and the conceptual framework established, express it on a canvas. Take a moment to gather thoughts and clear the mind. Use the design philosophy created and the instructions below to craft a masterpiece, embodying all aspects of the philosophy with expert craftsmanship.
|
||||
|
||||
**IMPORTANT**: For any type of content, even if the user requests something for a movie/game/book, the approach should still be sophisticated. Never lose sight of the idea that this should be art, not something that's cartoony or amateur.
|
||||
|
||||
To create museum or magazine quality work, use the design philosophy as the foundation. Create one single page, highly visual, design-forward PDF or PNG output (unless asked for more pages). Generally use repeating patterns and perfect shapes. Treat the abstract philosophical design as if it were a scientific bible, borrowing the visual language of systematic observation—dense accumulation of marks, repeated elements, or layered patterns that build meaning through patient repetition and reward sustained viewing. Add sparse, clinical typography and systematic reference markers that suggest this could be a diagram from an imaginary discipline, treating the invisible subject with the same reverence typically reserved for documenting observable phenomena. Anchor the piece with simple phrase(s) or details positioned subtly, using a limited color palette that feels intentional and cohesive. Embrace the paradox of using analytical visual language to express ideas about human experience: the result should feel like an artifact that proves something ephemeral can be studied, mapped, and understood through careful attention. This is true art.
|
||||
|
||||
**Text as a contextual element**: Text is always minimal and visual-first, but let context guide whether that means whisper-quiet labels or bold typographic gestures. A punk venue poster might have larger, more aggressive type than a minimalist ceramics studio identity. Most of the time, font should be thin. All use of fonts must be design-forward and prioritize visual communication. Regardless of text scale, nothing falls off the page and nothing overlaps. Every element must be contained within the canvas boundaries with proper margins. Check carefully that all text, graphics, and visual elements have breathing room and clear separation. This is non-negotiable for professional execution. **IMPORTANT: Use different fonts if writing text. Search the `./canvas-fonts` directory. Regardless of approach, sophistication is non-negotiable.**
|
||||
|
||||
Download and use whatever fonts are needed to make this a reality. Get creative by making the typography actually part of the art itself -- if the art is abstract, bring the font onto the canvas, not typeset digitally.
|
||||
|
||||
To push boundaries, follow design instinct/intuition while using the philosophy as a guiding principle. Embrace ultimate design freedom and choice. Push aesthetics and design to the frontier.
|
||||
|
||||
**CRITICAL**: To achieve human-crafted quality (not AI-generated), create work that looks like it took countless hours. Make it appear as though someone at the absolute top of their field labored over every detail with painstaking care. Ensure the composition, spacing, color choices, typography - everything screams expert-level craftsmanship. Double-check that nothing overlaps, formatting is flawless, every detail perfect. Create something that could be shown to people to prove expertise and rank as undeniably impressive.
|
||||
|
||||
Output the final result as a single, downloadable .pdf or .png file, alongside the design philosophy used as a .md file.
|
||||
|
||||
---
|
||||
|
||||
## FINAL STEP
|
||||
|
||||
**IMPORTANT**: The user ALREADY said "It isn't perfect enough. It must be pristine, a masterpiece if craftsmanship, as if it were about to be displayed in a museum."
|
||||
|
||||
**CRITICAL**: To refine the work, avoid adding more graphics; instead refine what has been created and make it extremely crisp, respecting the design philosophy and the principles of minimalism entirely. Rather than adding a fun filter or refactoring a font, consider how to make the existing composition more cohesive with the art. If the instinct is to call a new function or draw a new shape, STOP and instead ask: "How can I make what's already here more of a piece of art?"
|
||||
|
||||
Take a second pass. Go back to the code and refine/polish further to make this a philosophically designed masterpiece.
|
||||
|
||||
## MULTI-PAGE OPTION
|
||||
|
||||
To create additional pages when requested, create more creative pages along the same lines as the design philosophy but distinctly different as well. Bundle those pages in the same .pdf or many .pngs. Treat the first page as just a single page in a whole coffee table book waiting to be filled. Make the next pages unique twists and memories of the original. Have them almost tell a story in a very tasteful way. Exercise full creative freedom.
|
||||
@@ -0,0 +1,10 @@
|
||||
# 上游溯源
|
||||
|
||||
- 来源:https://github.com/anthropics/skills (skills/canvas-design)
|
||||
- 上游固定版本:dbd4588f9e1033efb41dad4bef2f7947c8993d44(2026-10-14 抓取)
|
||||
- 许可证:Apache-2.0(本目录 LICENSE.txt 为上游原文);canvas-fonts/ 内字体各自带
|
||||
OFL 许可文本(*-OFL.txt)
|
||||
- 热度依据:anthropics 官方 example-skills 成员(skill-creator 同仓 405K 安装量级)
|
||||
- 适配:SKILL.md 与 canvas-fonts/ 原样保留;渲染依赖 PIL(Pillow,沙箱已具备)。
|
||||
- 适用范围:海报、封面图、信息图等 png/pdf 视觉产物。Office 交付物仍以
|
||||
house-style kit 生成;两者产物类型不同不冲突。
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2012 The Arsenal Project Authors (andrij.design@gmail.com)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2019 The Big Shoulders Project Authors (https://github.com/xotypeco/big_shoulders)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user