Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c9743ae42 | ||
|
|
dee7a9e845 | ||
|
|
e6c3d0d876 | ||
|
|
607c38d7e6 | ||
|
|
c8f8076579 | ||
|
|
aea948fd4d | ||
|
|
8e80e2618e | ||
|
|
a4e33f192f | ||
|
|
76e7cc7c18 | ||
|
|
571e2bd28a | ||
|
|
6dbe025ec1 | ||
|
|
5d5fc9891c | ||
|
|
d26f3cf2e8 | ||
|
|
d2f96e79bb | ||
|
|
c4435d1415 | ||
|
|
4f72aaf3d8 | ||
|
|
b9bea5bee0 | ||
|
|
7cf8f02399 | ||
|
|
15566f9a33 | ||
|
|
4e285bdd8d | ||
|
|
31a6b326c6 | ||
|
|
baaafe99d3 | ||
|
|
cfb8308895 | ||
|
|
bc1e0a36f7 | ||
|
|
fe977c8e52 | ||
|
|
e3b6cca8bc | ||
|
|
741da4a0b6 | ||
|
|
0af1ebc915 | ||
|
|
349a47de6f | ||
|
|
8b73afeef6 | ||
|
|
76c75ce50c | ||
|
|
496c784213 | ||
|
|
386afcd4a6 | ||
|
|
716fe9541c | ||
|
|
f103540d60 | ||
|
|
467718a519 | ||
|
|
dc736e339d | ||
|
|
46073d30a5 | ||
|
|
9754531cc9 | ||
|
|
c6fb13016a | ||
|
|
0f192a4813 | ||
|
|
545a75b54e | ||
|
|
f13e8b1ab7 | ||
|
|
5c26a86331 | ||
|
|
41e7eea0c3 | ||
|
|
69ea7dcc14 | ||
|
|
58b4c640d5 | ||
|
|
0191be52cd | ||
|
|
bff6820e5f | ||
|
|
f718d36d23 | ||
|
|
acabb85e2a | ||
|
|
5623000d51 | ||
|
|
d1f46c5646 | ||
|
|
830fea3d3b | ||
|
|
19d4e1ef0d | ||
|
|
430caa6296 | ||
|
|
bbe977351d | ||
|
|
0270b40197 | ||
|
|
4d48abaa8c | ||
|
|
5e8ada727c | ||
|
|
c963347e52 | ||
|
|
dcc180dcad | ||
|
|
e7b70c24f8 | ||
|
|
dcd5445d61 | ||
|
|
e046bbd15c | ||
|
|
1d9e8f4a79 | ||
|
|
52d75fd931 | ||
|
|
ed443ed3f6 | ||
|
|
c7b30d2109 | ||
|
|
4352f368cb | ||
|
|
cb386023d0 | ||
|
|
9bd0aae9d8 | ||
|
|
47463a9623 | ||
|
|
b6ea1a5910 | ||
|
|
41df7ae56f | ||
|
|
1ba7225e83 |
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func runCapabilities(args []string) int {
|
||||
fs := flag.NewFlagSet("capabilities", flag.ExitOnError)
|
||||
asJSON := fs.Bool("json", false, "以 JSON 打印能力,不打开数据库")
|
||||
_ = fs.Parse(args)
|
||||
caps := map[string]any{
|
||||
"accountTransaction": true,
|
||||
"accountAudit": true,
|
||||
"accountOpsOpen": true,
|
||||
"passwordStdin": true,
|
||||
"dsnFile": true,
|
||||
"outFile": true,
|
||||
}
|
||||
if !*asJSON {
|
||||
fmt.Fprintln(os.Stderr, "用法:agent-system capabilities --json")
|
||||
return 2
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(caps); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "capabilities:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func resolveAccountDSN(flagDSN, dsnFile string) (string, error) {
|
||||
if flagDSN != "" {
|
||||
fmt.Fprintln(os.Stderr, "warning: --dsn is deprecated; use AGENT_SYSTEM_DSN or --dsn-file")
|
||||
}
|
||||
if dsnFile != "" {
|
||||
info, err := os.Stat(dsnFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
return "", fmt.Errorf("dsn file %s must be mode 0600", dsnFile)
|
||||
}
|
||||
raw, err := os.ReadFile(dsnFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(trimNewline(raw)), nil
|
||||
}
|
||||
if env := os.Getenv("AGENT_SYSTEM_DSN"); env != "" {
|
||||
return env, nil
|
||||
}
|
||||
return flagDSN, nil
|
||||
}
|
||||
|
||||
func resolveAccountPassword(flagPassword string, fromStdin bool) (string, error) {
|
||||
if flagPassword != "" {
|
||||
fmt.Fprintln(os.Stderr, "warning: --password is deprecated; use --password-stdin")
|
||||
}
|
||||
if !fromStdin {
|
||||
return flagPassword, nil
|
||||
}
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(trimNewline(raw)), nil
|
||||
}
|
||||
|
||||
func writeExclusiveSecret(path, body string) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.WriteString(f, body+"\n")
|
||||
return err
|
||||
}
|
||||
|
||||
func trimNewline(raw []byte) []byte {
|
||||
for len(raw) > 0 && (raw[len(raw)-1] == '\n' || raw[len(raw)-1] == '\r') {
|
||||
raw = raw[:len(raw)-1]
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/store"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func accountCLI(t *testing.T, args ...string) (int, string) {
|
||||
t.Helper()
|
||||
out, err := os.CreateTemp(t.TempDir(), "capture-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer out.Close()
|
||||
oldOut, oldErr := os.Stdout, os.Stderr
|
||||
os.Stdout = out
|
||||
os.Stderr = out
|
||||
defer func() { os.Stdout = oldOut; os.Stderr = oldErr }()
|
||||
rc := runUser(args)
|
||||
out.Seek(0, 0)
|
||||
raw, err := io.ReadAll(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rc, string(raw)
|
||||
}
|
||||
func cliStore(t *testing.T) (string, *store.Store) {
|
||||
t.Helper()
|
||||
t.Setenv("AGENT_SYSTEM_DSN", "")
|
||||
dir := t.TempDir()
|
||||
s, e := store.OpenSQLite(filepath.Join(dir, "agent-system.db"))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
if e := s.EnsureSystemIdentity("fixture", "sqlite"); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return dir, s
|
||||
}
|
||||
func TestOutputFileAppliesToEveryCredentialCommand(t *testing.T) {
|
||||
for _, sub := range []string{"create", "reset-password", "issue-login-key", "bootstrap-admin"} {
|
||||
t.Run(sub, func(t *testing.T) {
|
||||
dir, s := cliStore(t)
|
||||
if sub != "create" && sub != "bootstrap-admin" {
|
||||
if e := s.CreateAccount("ada", "Ada", "", false); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
out := filepath.Join(t.TempDir(), "secret")
|
||||
rc, _ := accountCLI(t, sub, "--data", dir, "--owner", "ada", "--out-file", out)
|
||||
if rc != 0 {
|
||||
t.Fatalf("credential command failed: %d", rc)
|
||||
}
|
||||
info, e := os.Stat(out)
|
||||
if e != nil {
|
||||
t.Fatal("credential output file missing")
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatal("credential output permissions are not private")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestExistingOutputAndConflictingFormatsRefuseBeforeMutation(t *testing.T) {
|
||||
for _, kind := range []string{"existing", "conflicting"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
dir, s := cliStore(t)
|
||||
out := filepath.Join(t.TempDir(), "secret")
|
||||
args := []string{"create", "--data", dir, "--owner", "ada", "--out-file", out}
|
||||
if kind == "existing" {
|
||||
if e := os.WriteFile(out, []byte("existing"), 0600); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
} else {
|
||||
args = append(args, "--out", filepath.Join(t.TempDir(), "json-secret"))
|
||||
}
|
||||
rc, _ := accountCLI(t, args...)
|
||||
if rc == 0 {
|
||||
t.Fatal("unsafe output selection succeeded")
|
||||
}
|
||||
if _, e := s.GetUser("ada"); e == nil {
|
||||
t.Fatal("output refusal happened after account mutation")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeyCreateUsesExclusivePrivateOutput(t *testing.T) {
|
||||
if dir := os.Getenv("U7_APIKEY_CHILD_DIR"); dir != "" {
|
||||
rc := runAPIKey([]string{"create", "--data", dir, "--owner", "ada", "--name", "fixture", "--out-file", os.Getenv("U7_APIKEY_CHILD_OUT")})
|
||||
if rc != 0 {
|
||||
t.Fatal("apikey create rejected its private output destination")
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, existing := range []bool{false, true} {
|
||||
name := "new"
|
||||
if existing {
|
||||
name = "existing"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dir, st := cliStore(t)
|
||||
if err := st.CreateAccount("ada", "Ada", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := filepath.Join(t.TempDir(), "credential")
|
||||
if existing {
|
||||
if err := os.WriteFile(out, []byte("preserve"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestAPIKeyCreateUsesExclusivePrivateOutput$")
|
||||
cmd.Env = append(os.Environ(), "U7_APIKEY_CHILD_DIR="+dir, "U7_APIKEY_CHILD_OUT="+out)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
if existing {
|
||||
if err == nil {
|
||||
t.Fatal("existing output was overwritten or mutation succeeded")
|
||||
}
|
||||
raw, readErr := os.ReadFile(out)
|
||||
if readErr != nil || string(raw) != "preserve" {
|
||||
t.Fatal("existing output was modified")
|
||||
}
|
||||
keys, listErr := st.ListAPIKeys("ada")
|
||||
if listErr != nil || len(keys) != 0 {
|
||||
t.Fatal("mutation happened before output preflight")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal("new private output was rejected")
|
||||
}
|
||||
info, statErr := os.Stat(out)
|
||||
if statErr != nil || info.Mode().Perm() != 0600 {
|
||||
t.Fatal("credential output missing or not private")
|
||||
}
|
||||
raw, readErr := os.ReadFile(out)
|
||||
if readErr != nil || len(raw) == 0 {
|
||||
t.Fatal("credential output empty")
|
||||
}
|
||||
if bytes.Contains(stdout.Bytes(), bytes.TrimSpace(raw)) {
|
||||
t.Fatal("credential was written to stdout")
|
||||
}
|
||||
keys, listErr := st.ListAPIKeys("ada")
|
||||
if listErr != nil || len(keys) != 1 {
|
||||
t.Fatal("successful issue was not committed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnknownSubcommandExits(t *testing.T) {
|
||||
if os.Getenv("DISPATCH_CHILD") == "1" {
|
||||
os.Args = []string{"agent-system", "not-a-command"}
|
||||
main()
|
||||
return
|
||||
}
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestUnknownSubcommandExits")
|
||||
cmd.Env = append(os.Environ(), "DISPATCH_CHILD=1")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
t.Fatalf("未知子命令应非零退出:%s", out)
|
||||
}
|
||||
if !strings.Contains(string(out), "未知子命令") {
|
||||
t.Fatalf("输出:%s", out)
|
||||
}
|
||||
if strings.Contains(string(out), "agent-system starting") || strings.Contains(string(out), "serving") {
|
||||
t.Fatalf("未知子命令落入了服务路径:%s", out)
|
||||
}
|
||||
}
|
||||
+313
-17
@@ -9,18 +9,29 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/app"
|
||||
"agent-system-backend/internal/config"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
"agent-system-backend/pkg/identity"
|
||||
)
|
||||
|
||||
var version = "0.1.0-dev"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
|
||||
switch os.Args[1] {
|
||||
case "version", "--version":
|
||||
if len(os.Args) > 2 && os.Args[2] == "--json" {
|
||||
if err := identity.WriteDocument(os.Stdout, "agent-system", version); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "version:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
fmt.Println("agent-system", version)
|
||||
return
|
||||
case "serve":
|
||||
@@ -31,6 +42,14 @@ func main() {
|
||||
os.Exit(runConfig(os.Args[2:]))
|
||||
case "user":
|
||||
os.Exit(runUser(os.Args[2:]))
|
||||
case "identity":
|
||||
os.Exit(runIdentity(os.Args[2:]))
|
||||
case "capabilities":
|
||||
os.Exit(runCapabilities(os.Args[2:]))
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "agent-system: 未知子命令 %s\n", os.Args[1])
|
||||
fmt.Fprintln(os.Stderr, "用法:agent-system <serve|version|identity|user|apikey|config|capabilities> [参数]")
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||
@@ -55,7 +74,19 @@ func main() {
|
||||
pgIdleSec := fs.Int("pg-conn-max-idle-sec", 0, "PG 空闲连接回收秒数(0=默认 300)")
|
||||
pgLifetimeSec := fs.Int("pg-conn-max-lifetime-sec", 0, "PG 连接最长生命周期秒数(0=不限)")
|
||||
pgQueryTimeoutSec := fs.Int("pg-query-timeout-sec", 0, "PG statement_timeout 秒数(0=不限;覆盖全部业务查询)")
|
||||
extension.RegisterCLIFlags(fs)
|
||||
fs.Parse(os.Args[1:])
|
||||
handled, hookErr := extension.RunCLIHooks(extension.Options{
|
||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
||||
StorageDSN: *dsn, AuthMode: *auth, DisableExecTools: *noExec, ExecBackend: *execBackend,
|
||||
})
|
||||
if hookErr != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", hookErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
if handled {
|
||||
return
|
||||
}
|
||||
cliSet := visited(fs)
|
||||
|
||||
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
|
||||
@@ -76,11 +107,36 @@ func main() {
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
pluginDefaults, merr := extension.MergeHostOptions(extension.Options{})
|
||||
if merr != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", merr)
|
||||
os.Exit(1)
|
||||
}
|
||||
disableExec := resolved.DisableExecTools
|
||||
if !cliSet["disableExecTools"] {
|
||||
disableExec = disableExec || pluginDefaults.DisableExecTools
|
||||
}
|
||||
execBackendResolved := resolved.ExecBackend
|
||||
if !cliSet["execBackend"] && execBackendResolved == "" {
|
||||
execBackendResolved = pluginDefaults.ExecBackend
|
||||
}
|
||||
extOpt := extension.Options{
|
||||
Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir,
|
||||
DisableUploadIndexing: pluginDefaults.DisableUploadIndexing,
|
||||
DisableConvCardMirror: pluginDefaults.DisableConvCardMirror,
|
||||
DisableExecTools: disableExec, ExecBackend: execBackendResolved,
|
||||
}
|
||||
if err := extension.PrepareCompiled(&extOpt); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := app.Run(app.Options{
|
||||
Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock,
|
||||
StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode,
|
||||
InstanceID: resolved.Instance, InsecureListen: resolved.InsecureListen, AllowedHosts: resolved.AllowedHosts(),
|
||||
DisableExecTools: resolved.DisableExecTools, ExecBackend: resolved.ExecBackend, ExecDepsDir: resolved.ExecDepsDir,
|
||||
DisableUploadIndexing: extOpt.DisableUploadIndexing,
|
||||
DisableConvCardMirror: extOpt.DisableConvCardMirror,
|
||||
DisableExecTools: extOpt.DisableExecTools, ExecBackend: extOpt.ExecBackend, ExecDepsDir: resolved.ExecDepsDir,
|
||||
ExecWorkspace: resolved.ExecWorkspace,
|
||||
Tier: resolved.Tier,
|
||||
PGPool: store.PoolOptions{
|
||||
@@ -178,22 +234,43 @@ 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 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)
|
||||
@@ -222,36 +299,194 @@ func runAPIKey(args []string) int {
|
||||
}
|
||||
}
|
||||
|
||||
// runUser:用户账号管理子命令(管理员发放制,R2)——create / reset-password / list。
|
||||
// 密码未显式给定时生成随机值,明文仅此一次输出;创建/重置后首次登录强制改密。
|
||||
// deliverCredential 凭据单次交付(KTD14):--out 时写 0600 JSON 文件,stdout 只报
|
||||
// 属主/用途/文件路径不含明文;缺省沿用 stdout 一次输出。文件含 expiresAt 便于交接。
|
||||
func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) error {
|
||||
if outPath == "" {
|
||||
fmt.Println(plaintext)
|
||||
return nil
|
||||
}
|
||||
payload := map[string]any{"kind": kind, "owner": owner, "secret": plaintext}
|
||||
if expiresAt != nil {
|
||||
payload["expiresAt"] = expiresAt
|
||||
}
|
||||
buf, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeExclusiveSecret(outPath, string(buf)); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", outPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
type credentialOutput struct {
|
||||
path string
|
||||
file *os.File
|
||||
json bool
|
||||
mutated bool
|
||||
}
|
||||
|
||||
// Reserve the destination before changing the account. O_EXCL also rejects an
|
||||
// existing symlink, so an existing secret can never be overwritten.
|
||||
func reserveCredentialOutput(rawPath, jsonPath string) (*credentialOutput, error) {
|
||||
if rawPath != "" && jsonPath != "" {
|
||||
return nil, fmt.Errorf("--out and --out-file are mutually exclusive")
|
||||
}
|
||||
out := &credentialOutput{}
|
||||
out.path, out.json = rawPath, false
|
||||
if jsonPath != "" {
|
||||
out.path, out.json = jsonPath, true
|
||||
}
|
||||
if out.path == "" {
|
||||
return out, nil
|
||||
}
|
||||
f, err := os.OpenFile(out.path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.file = f
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (out *credentialOutput) abort() {
|
||||
if out == nil || out.file == nil {
|
||||
return
|
||||
}
|
||||
info, err := out.file.Stat()
|
||||
_ = out.file.Close()
|
||||
if out.mutated || err != nil {
|
||||
return
|
||||
}
|
||||
pathInfo, pathErr := os.Stat(out.path)
|
||||
if pathErr == nil && os.SameFile(info, pathInfo) {
|
||||
_ = os.Remove(out.path)
|
||||
}
|
||||
}
|
||||
|
||||
func (out *credentialOutput) deliver(kind, owner, plaintext string, expiresAt any) error {
|
||||
if out.file == nil {
|
||||
return deliverCredential("", kind, owner, plaintext, expiresAt)
|
||||
}
|
||||
out.mutated = true // Keep the private recovery artifact if delivery fails after DB commit.
|
||||
var body []byte
|
||||
if out.json {
|
||||
payload := map[string]any{"kind": kind, "owner": owner, "secret": plaintext}
|
||||
if expiresAt != nil {
|
||||
payload["expiresAt"] = expiresAt
|
||||
}
|
||||
var err error
|
||||
body, err = json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
body = []byte(plaintext)
|
||||
}
|
||||
body = append(body, '\n')
|
||||
if _, err := out.file.Write(body); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := out.file.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := out.file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
out.file = nil
|
||||
fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", out.path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runUser:create / reset-password / list / issue-login-key / revoke-login-key / bootstrap-admin。
|
||||
// 默认 create 无初始密码并签发激活 Key;--generate-password 兼容旧随机密码路径。
|
||||
func runUser(args []string) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "用法:agent-system user <create|reset-password|list> [--owner x --name y --admin --password p]")
|
||||
fmt.Fprintln(os.Stderr, "用法:agent-system user <create|reset-password|list|issue-login-key|revoke-login-key|bootstrap-admin> [flags]")
|
||||
return 2
|
||||
}
|
||||
sub := args[0]
|
||||
fs := flag.NewFlagSet("user "+sub, flag.ExitOnError)
|
||||
data := fs.String("data", "./data", "数据目录")
|
||||
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)")
|
||||
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档;已弃用,优先 AGENT_SYSTEM_DSN 或 --dsn-file)")
|
||||
dsnFile := fs.String("dsn-file", "", "0600 权限的 DSN 文件")
|
||||
owner := fs.String("owner", "", "用户名(owner)")
|
||||
name := fs.String("name", "", "显示名")
|
||||
password := fs.String("password", "", "密码(空 = 随机生成并打印一次)")
|
||||
admin := fs.Bool("admin", false, "管理员(可管理用户与密钥)")
|
||||
password := fs.String("password", "", "密码(已弃用;请用 --password-stdin)")
|
||||
passwordStdin := fs.Bool("password-stdin", false, "从 stdin 读取密码")
|
||||
outFile := fs.String("out-file", "", "把明文凭据独占写入该文件(0600)")
|
||||
instanceID := fs.String("instance-id", "", "严格核验库内服务身份")
|
||||
admin := fs.Bool("admin", false, "管理员")
|
||||
role := fs.String("role", "user", "角色 user|admin")
|
||||
openExisting := fs.Bool("open-existing", false, "兼容旧参数;账户命令始终只打开既有存储")
|
||||
generatePassword := fs.Bool("generate-password", false, "create 时生成随机初始密码")
|
||||
purpose := fs.String("purpose", "recover", "issue-login-key:activate|recover")
|
||||
ttlMin := fs.Int("ttl-minutes", 30, "登录 Key 有效分钟(5–120)")
|
||||
revokeAll := fs.Bool("revoke-all-credentials", false, "签发时撤销全部长期 API Key 与会话")
|
||||
keyID := fs.String("id", "", "revoke-login-key 凭据编号")
|
||||
out := fs.String("out", "", "凭据交付文件(0600 JSON,代替 stdout 明文;非交互推荐)")
|
||||
fs.Parse(args[1:])
|
||||
resolvedDSN, err := resolveAccountDSN(*dsn, *dsnFile)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user:", err)
|
||||
return 1
|
||||
}
|
||||
resolvedPassword, err := resolveAccountPassword(*password, *passwordStdin)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user:", err)
|
||||
return 1
|
||||
}
|
||||
if *instanceID != "" {
|
||||
_ = os.Setenv("AGENT_SYSTEM_INSTANCE_ID", *instanceID)
|
||||
}
|
||||
*dsn = resolvedDSN
|
||||
*password = resolvedPassword
|
||||
credentialAction := sub == "create" || sub == "reset-password" || sub == "issue-login-key" || sub == "bootstrap-admin"
|
||||
if !credentialAction && (*outFile != "" || *out != "") {
|
||||
fmt.Fprintln(os.Stderr, "user: 此子命令不生成凭据")
|
||||
return 2
|
||||
}
|
||||
var delivery *credentialOutput
|
||||
if credentialAction {
|
||||
delivery, err = reserveCredentialOutput(*outFile, *out)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user:", err)
|
||||
return 2
|
||||
}
|
||||
defer delivery.abort()
|
||||
}
|
||||
switch sub {
|
||||
case "create":
|
||||
if *owner == "" {
|
||||
fmt.Fprintln(os.Stderr, "user create: --owner 必填")
|
||||
return 2
|
||||
}
|
||||
pw, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, *admin)
|
||||
isAdmin := *admin
|
||||
switch *role {
|
||||
case "user":
|
||||
case "admin":
|
||||
isAdmin = true
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "user create: 未知角色 %q\n", *role)
|
||||
return 2
|
||||
}
|
||||
pw, opID, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user create:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println(pw)
|
||||
if *password == "" {
|
||||
fmt.Fprintln(os.Stderr, "初始密码仅此一次输出;该用户首次登录须修改密码。")
|
||||
if err := delivery.deliver("login-key", *owner, pw, nil); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user create:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "operation_id=%s\n", opID)
|
||||
if *out == "" && *outFile == "" {
|
||||
if *password == "" && !*generatePassword {
|
||||
fmt.Fprintln(os.Stderr, "一次性激活 Key 仅此一次输出;兑换后设置密码。")
|
||||
} else if *password == "" {
|
||||
fmt.Fprintln(os.Stderr, "初始密码仅此一次输出;该用户首次登录须修改密码。")
|
||||
}
|
||||
}
|
||||
return 0
|
||||
case "reset-password":
|
||||
@@ -259,18 +494,21 @@ func runUser(args []string) int {
|
||||
fmt.Fprintln(os.Stderr, "user reset-password: --owner 必填")
|
||||
return 2
|
||||
}
|
||||
pw, err := app.ResetUserPassword(*dsn, *data, *owner, *password)
|
||||
pw, err := app.ResetUserPassword(*dsn, *data, *owner, *password, *openExisting)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user reset-password:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println(pw)
|
||||
if *password == "" {
|
||||
if err := delivery.deliver("password", *owner, pw, nil); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user reset-password:", err)
|
||||
return 1
|
||||
}
|
||||
if *out == "" && *outFile == "" && *password == "" {
|
||||
fmt.Fprintln(os.Stderr, "新密码仅此一次输出;该用户下次登录须修改密码(其余会话已吊销)。")
|
||||
}
|
||||
return 0
|
||||
case "list":
|
||||
users, err := app.ListUserAccounts(*dsn, *data)
|
||||
users, err := app.ListUserAccounts(*dsn, *data, *openExisting)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user list:", err)
|
||||
return 1
|
||||
@@ -278,8 +516,66 @@ func runUser(args []string) int {
|
||||
out, _ := json.MarshalIndent(users, "", " ")
|
||||
fmt.Println(string(out))
|
||||
return 0
|
||||
case "issue-login-key":
|
||||
if *owner == "" {
|
||||
fmt.Fprintln(os.Stderr, "user issue-login-key: --owner 必填")
|
||||
return 2
|
||||
}
|
||||
if *purpose != store.LoginKeyPurposeActivate && *purpose != store.LoginKeyPurposeRecover {
|
||||
fmt.Fprintf(os.Stderr, "user issue-login-key: 无效 purpose %q\n", *purpose)
|
||||
return 2
|
||||
}
|
||||
plain, err := app.IssueUserLoginKey(*dsn, *data, *owner, *purpose, *openExisting, *revokeAll, time.Duration(*ttlMin)*time.Minute)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user issue-login-key:", err)
|
||||
return 1
|
||||
}
|
||||
if err := delivery.deliver("login-key", *owner, plain, time.Now().Add(time.Duration(*ttlMin)*time.Minute).UTC()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user issue-login-key:", err)
|
||||
return 1
|
||||
}
|
||||
if *out == "" && *outFile == "" {
|
||||
fmt.Fprintln(os.Stderr, "一次性登录 Key 仅此一次输出。")
|
||||
}
|
||||
return 0
|
||||
case "revoke-login-key":
|
||||
if *keyID == "" {
|
||||
fmt.Fprintln(os.Stderr, "user revoke-login-key: --id 必填")
|
||||
return 2
|
||||
}
|
||||
if err := app.RevokeUserLoginKey(*dsn, *data, *keyID, *openExisting); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user revoke-login-key:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println("revoked")
|
||||
return 0
|
||||
case "bootstrap-admin":
|
||||
if *owner == "" {
|
||||
fmt.Fprintln(os.Stderr, "user bootstrap-admin: --owner 必填")
|
||||
return 2
|
||||
}
|
||||
plain, err := app.BootstrapAdmin(*dsn, *data, *owner, *openExisting)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err)
|
||||
return 1
|
||||
}
|
||||
if err := delivery.deliver("bootstrap-admin", *owner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err)
|
||||
return 1
|
||||
}
|
||||
if *out == "" && *outFile == "" {
|
||||
fmt.Fprintln(os.Stderr, "首位管理员激活 Key 仅此一次输出。")
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "user: 未知子命令", sub)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// runIdentity:U1 I3 服务实例库内身份(manifest.instance_id ↔ agent_system_identity)。
|
||||
// 与 serve --instance(runs 进程标识)无关。实现在 pkg/identity(公共化:下游
|
||||
// 组合仓共用同一 CLI,未知子命令 usage 退出、不落服务路径——2026-10-12 裁定)。
|
||||
func runIdentity(args []string) int {
|
||||
return identity.Run(args)
|
||||
}
|
||||
|
||||
@@ -284,13 +284,19 @@ var copyOrder = []string{
|
||||
"projects", "users", "profiles", "settings", "plugins",
|
||||
"conversations", "runs", "messages", "events", "artifacts",
|
||||
"hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors",
|
||||
"activity_cards", "command_audit", "prompt_modules",
|
||||
"activity_cards", "project_event_shares", "command_audit", "prompt_modules",
|
||||
"sessions", "api_keys",
|
||||
"plugin_grants", "plugin_schema", "plugin_kv", "background_tasks",
|
||||
"memory_entries", "audit_events", "pending_confirms", "llm_usage_daily",
|
||||
"secret_keys", "settings_snapshots", "regen_pending", "context_entries",
|
||||
"user_commands", "pending_notices", "recovery_holds",
|
||||
"surfaces", "surface_revisions", "surface_actions", "plugin_activation",
|
||||
// v26 login_keys(#171 合入 main 时漏登记;本 PR 一并补齐)
|
||||
"login_keys",
|
||||
// U1 I3:服务实例 DB 身份(无外键;迁移须保留以免目标库丢锚)
|
||||
"agent_system_identity",
|
||||
"account_events",
|
||||
"redeem_attempts",
|
||||
}
|
||||
|
||||
const controlDDL = `
|
||||
@@ -778,7 +784,7 @@ func advanceSequences(db *sql.DB, table string) error {
|
||||
// checkOwners:业务行引用的 owner 必须存在于 users(单租户默认 ”/'local'
|
||||
// 除外)。未知 owner 阻塞迁移(U4/R7:不静默带过未知归属)。
|
||||
func checkOwners(db *sql.DB, src *sql.DB) error {
|
||||
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "command_audit"}
|
||||
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "project_event_shares", "command_audit"}
|
||||
for _, t := range ownerTables {
|
||||
if !sqliteHasTable(src, t) {
|
||||
continue
|
||||
|
||||
Vendored
+1
-1
Submodule deps/agent-system-core updated: 89f3906c39...5773e74086
@@ -0,0 +1,108 @@
|
||||
package app
|
||||
|
||||
// EXT-19 消费测试:inprocHost.ListAccounts——最小目录(owner/valid)、游标分页、
|
||||
// limit 钳制(1-200,缺省 50)。数据来自 users 表(真实 store,非桩)。
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func TestInprocHostListAccounts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := store.OpenSQLite(dir + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
for _, o := range []string{"carol", "alice", "bob", "dave"} {
|
||||
if _, err := st.CreateAPIKey(o, "e2e"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
h := inprocHost{caps: hostcaps{st: st}}
|
||||
|
||||
// 首页(limit=2):alice/bob,next=bob。
|
||||
page, next, err := h.ListAccounts("", 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page) != 2 || page[0].Owner != "alice" || page[1].Owner != "bob" {
|
||||
t.Fatalf("首页不符: %+v", page)
|
||||
}
|
||||
for _, e := range page {
|
||||
if !e.Valid {
|
||||
t.Fatalf("现有账号应 valid: %+v", e)
|
||||
}
|
||||
}
|
||||
if next != "bob" {
|
||||
t.Fatalf("next 应为 bob, got %q", next)
|
||||
}
|
||||
// 次页:carol/dave,next=""(末页)。
|
||||
page2, next2, _ := h.ListAccounts(next, 2)
|
||||
if len(page2) != 2 || page2[0].Owner != "carol" || page2[1].Owner != "dave" || next2 != "" {
|
||||
t.Fatalf("次页不符: %+v next=%q", page2, next2)
|
||||
}
|
||||
// limit 钳制:0 → 50 缺省;超 200 → 200(不炸)。
|
||||
if p, _, _ := h.ListAccounts("", 0); len(p) != 4 {
|
||||
t.Fatalf("limit=0 应取缺省(4 账号全量), got %d", len(p))
|
||||
}
|
||||
if _, _, err := h.ListAccounts("", 999); err != nil {
|
||||
t.Fatalf("limit 超限应钳制不报错: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInprocHostSearchAccounts:EXT-19 扩展 SearchAccounts——账号/姓名子串
|
||||
// 检索、大小写不敏感、空 query 首页、limit 钳制(P3,2026-10-13)。
|
||||
func TestInprocHostSearchAccounts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := store.OpenSQLite(dir + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
hash, err := store.HashPassword("init-pass-01")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("alice", "王小明", hash, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("bob", "李大明", hash, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("carol", "", hash, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := inprocHost{caps: hostcaps{st: st}}
|
||||
|
||||
// 姓名子串(同名区分场景):两个"大明/小明"都以"明"命中。
|
||||
got, err := h.SearchAccounts("明", 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || got[0].Owner != "alice" || got[1].Owner != "bob" {
|
||||
t.Fatalf("姓名子串命中不符: %+v", got)
|
||||
}
|
||||
if got[0].Name != "王小明" {
|
||||
t.Fatalf("目录行应携带姓名: %+v", got[0])
|
||||
}
|
||||
// 账号子串、大小写不敏感。
|
||||
got, _ = h.SearchAccounts("AL", 50)
|
||||
if len(got) != 1 || got[0].Owner != "alice" {
|
||||
t.Fatalf("账号子串应命中 alice: %+v", got)
|
||||
}
|
||||
// 未命中。
|
||||
if got, _ = h.SearchAccounts("不存在", 50); len(got) != 0 {
|
||||
t.Fatalf("未命中应为空: %+v", got)
|
||||
}
|
||||
// 空 query=首页候选(owner 升序);姓名为空的账号 Name 为空串。
|
||||
if got, _ = h.SearchAccounts("", 2); len(got) != 2 || got[0].Owner != "alice" || got[1].Name != "李大明" {
|
||||
t.Fatalf("空 query 首页不符: %+v", got)
|
||||
}
|
||||
// ListAccounts 回归:分页行同样携带姓名。
|
||||
page, _, _ := h.ListAccounts("", 2)
|
||||
if page[0].Name != "王小明" {
|
||||
t.Fatalf("ListAccounts 应携带姓名: %+v", page[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/store"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAccountMaintenanceDefaultsToNonCreatingOpen(t *testing.T) {
|
||||
t.Setenv("AGENT_SYSTEM_DSN", "")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
run func(string) error
|
||||
}{
|
||||
{"user", func(dir string) error {
|
||||
_, _, err := CreateUserAccount("", dir, "ada", "Ada", "", false, false, false)
|
||||
return err
|
||||
}},
|
||||
{"apikey", func(dir string) error {
|
||||
_, err := CreateAPIKey("", dir, "ada", "test")
|
||||
return err
|
||||
}},
|
||||
{"identity verify", func(dir string) error {
|
||||
_, err := VerifySystemIdentity("", dir, "fixture", "sqlite")
|
||||
return err
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "absent")
|
||||
if err := tc.run(dir); err == nil {
|
||||
t.Error("maintenance created a missing database")
|
||||
}
|
||||
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||
t.Error("failed maintenance created a directory")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestAccountMaintenanceCannotImplicitlyMigrate(t *testing.T) {
|
||||
t.Setenv("AGENT_SYSTEM_DSN", "")
|
||||
dir := t.TempDir()
|
||||
st, err := store.OpenSQLite(filepath.Join(dir, "agent-system.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.EnsureSystemIdentity("test", "sqlite"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Roll back the newest additive migration in this private fixture only.
|
||||
if err := st.MigrateDown(store.SchemaTarget() - 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Close()
|
||||
if _, _, err := CreateUserAccount("", dir, "ada", "Ada", "", false, false, false); err == nil {
|
||||
t.Error("account command implicitly migrated an older schema")
|
||||
}
|
||||
}
|
||||
+240
-89
@@ -55,6 +55,7 @@ import (
|
||||
"agent-system-backend/internal/surfacesvc"
|
||||
"agent-system-backend/internal/tools"
|
||||
"agent-system-backend/internal/workflow"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
@@ -199,6 +200,21 @@ func Run(opt Options) error {
|
||||
return fmt.Errorf("open store: %w", err)
|
||||
}
|
||||
defer st.Close()
|
||||
// U1 I3:svc 注入的服务实例身份(manifest.instance_id)与库内 marker 对齐;
|
||||
// 未设置则跳过(兼容未接线旧调用方)。与 --instance(runs 进程标识)无关。
|
||||
if sysID := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")); sysID != "" {
|
||||
dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF"))
|
||||
if dbref == "" {
|
||||
if opt.StorageDSN != "" {
|
||||
dbref = "pg"
|
||||
} else {
|
||||
dbref = "sqlite"
|
||||
}
|
||||
}
|
||||
if err := st.VerifySystemIdentity(sysID, dbref); err != nil {
|
||||
return fmt.Errorf("system identity: %w", err)
|
||||
}
|
||||
}
|
||||
pool := st.Pool()
|
||||
if versions, verr := st.MigrationStatus(); verr == nil {
|
||||
log.Printf("storage tier: %s (dialect=%s pool=%d/%d idleSec=%d lifetimeSec=%d schema=v%d)",
|
||||
@@ -280,7 +296,12 @@ func Run(opt Options) error {
|
||||
if owner == "" {
|
||||
owner = store.OwnerDefault
|
||||
}
|
||||
return st.ActiveSecret(owner, service)
|
||||
if v, ok := st.ActiveSecret(owner, service); ok {
|
||||
return v, true
|
||||
}
|
||||
// P2 受管:密钥统一迁入 secrets 表后,系统层受管密钥挂在系统行
|
||||
//(owner=settings.OwnerSystemRow);租户未单独配置时回退系统密钥。
|
||||
return st.ActiveSecret(settings.OwnerSystemRow, service)
|
||||
}
|
||||
rag.SecretLookup = func(service, fallback string) string {
|
||||
return runtime.LookupSecret(store.OwnerDefault, service, fallback)
|
||||
@@ -417,7 +438,10 @@ func Run(opt Options) error {
|
||||
return fmt.Errorf("default profile: %w", err)
|
||||
}
|
||||
if opt.ForceMock {
|
||||
if err := setSvc.MergeUser(store.OwnerDefault, map[string]any{"runtime": map[string]any{"mock": true}}); err != nil {
|
||||
// runtime.* 自 #188 起为系统受管项:启动选项属受信操作员输入,
|
||||
// 播种走系统层(对所有租户生效);用户层写入会被受管拦截拒绝,
|
||||
// 导致 serve --mock 直接启动失败(multi-instance 集成回归)。
|
||||
if err := setSvc.MergeSystem(map[string]any{"runtime": map[string]any{"mock": true}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -471,6 +495,9 @@ func Run(opt Options) error {
|
||||
logger.Error("plugin load failed", perr, "plugin", id, "code", string(errcode.SysUnavailable))
|
||||
}
|
||||
}
|
||||
// P2 受管:插件声明里带 scope=system 的设置项同样纳入受管路径
|
||||
//(user/project 写入拒绝、生效链剥除)。
|
||||
setSvc.SetExtraSchema(pluginHost.SettingsSchema())
|
||||
|
||||
// 检索管线(4.12):分块+全文+(配置时)向量混合与 rerank;未配置自动退化为全文。
|
||||
pipe := rag.NewPipeline(st, setSvc, nil, nil)
|
||||
@@ -501,6 +528,7 @@ func Run(opt Options) error {
|
||||
|
||||
// 收件箱任务卡:事件走会话事件管道(持久化/seq/重放);关闭即 T0 基线。
|
||||
cardSvc := cardsvc.New(st, mgr.EmitCardEvent)
|
||||
cardSvc.SetProjectSharedEmitter(mgr.EmitProjectSharedCardEvent)
|
||||
surfaceSvc := surfacesvc.New(st, mgr.EmitCardEvent)
|
||||
go func() { // 启动恢复:重发「快照已落盘、事件未发出」的待恢复投影(KTD)。
|
||||
if n, err := surfaceSvc.RecoverPendingEvents(context.Background()); err == nil && n > 0 {
|
||||
@@ -535,7 +563,11 @@ func Run(opt Options) error {
|
||||
if resp.Receipt.State != "" {
|
||||
state = string(resp.Receipt.State)
|
||||
}
|
||||
return surfacesvc.ActionOutcome{State: state, CardID: resp.Card.ID, Receipt: resp.Receipt, Payload: resp.Payload}, nil
|
||||
cardID := ""
|
||||
if resp.Card != nil {
|
||||
cardID = string(resp.Card.ID)
|
||||
}
|
||||
return surfacesvc.ActionOutcome{State: state, CardID: cardID, Receipt: resp.Receipt, Payload: resp.Payload}, nil
|
||||
})
|
||||
// U3:generative-ui 工具注入闸——activation 台账关闭时命令不投影给该 owner
|
||||
// 的模型(禁用即不注入能力;执行端守卫仍在 surfacesvc)。
|
||||
@@ -564,7 +596,8 @@ func Run(opt Options) error {
|
||||
pluginHost.OnCommands = bus.RegisterPluginCommands
|
||||
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
|
||||
authzSlot := &projectAuthz{}
|
||||
caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc}
|
||||
projectSlot := &projectLifecycleSlot{}
|
||||
caps := hostcaps{projects: projectSlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc}
|
||||
// EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面
|
||||
// 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。
|
||||
mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
|
||||
@@ -577,6 +610,16 @@ func Run(opt Options) error {
|
||||
// EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。
|
||||
wireScopedHostFactory(caps)
|
||||
compiledSummary := applyCompiled(skillsRoot, bus, caps)
|
||||
mgr.SetProjectWorkAuthorization(projectSlot.work())
|
||||
mgr.SetKnowledgeProjectScope(func(ctx context.Context, actor string, id core.ID) (core.ID, error) {
|
||||
return projectSlot.resolve(ctx, caps, actor, id)
|
||||
})
|
||||
if authzSlot.hasV2() {
|
||||
mgr.SetKnowledgeResourceAllow(func(actor string, d core.KnowledgeDoc) bool {
|
||||
ok, err := authzSlot.AuthorizeResource(extension.ResourceAuthzQuery{Actor: actor, Action: "model-context", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source})
|
||||
return err == nil && ok
|
||||
})
|
||||
}
|
||||
mgr.OnUsage = func(owner string, tokens int) {
|
||||
budget := int(setSvc.Effective(owner, "").GetNumber("llm.dailyTokenBudget", 0))
|
||||
status, aerr := st.AccountTokens(owner, tokens, budget)
|
||||
@@ -601,26 +644,21 @@ func Run(opt Options) error {
|
||||
}
|
||||
// EXT-8 余项:hostapi 检索面同样吃适配器共享(本人 + 放行的他人项目知识)。
|
||||
hostSrv.SharedSearch = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
|
||||
own, err := caps.searchDefault(ctx, owner, text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shared, err := st.SearchKnowledgeShared(owner, core.RetrievalQuery{Text: text, TopK: 5}, func(docOwner, projectID core.ID) bool {
|
||||
ok, aerr := authzSlot.Authorize(owner, projectID)
|
||||
return aerr == nil && ok
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(own, shared...), nil
|
||||
return caps.searchDefault(ctx, owner, text)
|
||||
}
|
||||
|
||||
hostSrv.Complete = func(ctx context.Context, owner, prompt string) (string, error) {
|
||||
return caps.complete(ctx, owner, prompt)
|
||||
}
|
||||
|
||||
srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost)
|
||||
projectSlot.wire(srv, caps)
|
||||
// EXT-8:装载期注册的资源授权适配器注入读取入口(未注册=默认拒绝,行为不变)。
|
||||
srv.SetProjectAuthorizer(authzSlot.Authorize)
|
||||
// EXT-8b:资源级适配器注入读面(未注册=server 回落 V1,行为不变)。
|
||||
if authzSlot.hasV2() {
|
||||
srv.SetResourceAuthorizerV2(authzSlot.AuthorizeResource)
|
||||
}
|
||||
// EXT-9:宿主可关闭上传自动索引(发布决定移交下游命令层)。
|
||||
srv.DisableUploadIndexing = opt.DisableUploadIndexing
|
||||
// EXT-12 最小开关:会话卡不镜像进项目公共流(cardSvc 在上方已建)。
|
||||
@@ -700,38 +738,98 @@ func Run(opt Options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateUserAccount CLI 建号(管理员发放制):未给密码则生成随机 12 位并返回明文(仅此一次输出)。
|
||||
func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin bool) (string, error) {
|
||||
if password == "" {
|
||||
password = server.RandomPassword()
|
||||
}
|
||||
hash, err := store.HashPassword(password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := withStore(storageDSN, dataDir, func(st *store.Store) error {
|
||||
return st.CreateAccount(owner, name, hash, isAdmin)
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
// ResetUserPassword CLI 重置密码:must_change=1(首登强制改密),吊销全部会话。
|
||||
func ResetUserPassword(storageDSN, dataDir, owner, password string) (string, error) {
|
||||
if password == "" {
|
||||
password = server.RandomPassword()
|
||||
}
|
||||
hash, err := store.HashPassword(password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := withStore(storageDSN, dataDir, func(st *store.Store) error {
|
||||
if err := st.SetUserPassword(owner, hash); err != nil {
|
||||
// CreateUserAccount CLI 建号。
|
||||
// password 空且 generatePassword=false:无初始密码,同事务签发 activate Key,返回明文 Key。
|
||||
// generatePassword=true 或显式 password:兼容旧路径(随机/给定密码 + must_change)。
|
||||
func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, string, error) {
|
||||
var out, opID string
|
||||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
if password != "" || generatePassword {
|
||||
pw := password
|
||||
if pw == "" {
|
||||
pw = server.RandomPassword()
|
||||
}
|
||||
hash, err := store.HashPassword(pw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outc, err := st.CreateActivatedAccount(store.AccountWrite{
|
||||
Owner: owner, Name: name, PasswordHash: hash, IsAdmin: isAdmin, MustChange: true,
|
||||
Actor: cliActor(), InstanceID: accountInstanceID(), Action: "account.create",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = pw
|
||||
opID = outc.OperationID
|
||||
return nil
|
||||
}
|
||||
outc, err := st.CreateActivatedAccount(store.AccountWrite{
|
||||
Owner: owner, Name: name, IsAdmin: isAdmin, Purpose: store.LoginKeyPurposeActivate, CreatedBy: "cli",
|
||||
Actor: cliActor(), InstanceID: accountInstanceID(), Action: "account.create",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st.DeleteUserSessions(owner, "")
|
||||
out = outc.Plain
|
||||
opID = outc.OperationID
|
||||
return nil
|
||||
})
|
||||
return out, opID, err
|
||||
}
|
||||
|
||||
// IssueUserLoginKey CLI 签发激活/恢复 Key。
|
||||
func IssueUserLoginKey(storageDSN, dataDir, owner, purpose string, openExisting, revokeAllCreds bool, ttl time.Duration) (string, error) {
|
||||
var plain string
|
||||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
if revokeAllCreds {
|
||||
outc, err := st.RevokeAllAndIssue(store.AccountWrite{
|
||||
Owner: owner, Purpose: purpose, CreatedBy: "cli", TTL: ttl,
|
||||
Actor: cliActor(), InstanceID: accountInstanceID(),
|
||||
})
|
||||
plain = outc.Plain
|
||||
return err
|
||||
}
|
||||
p, _, err := st.IssueLoginKey(owner, purpose, "cli", ttl)
|
||||
plain = p
|
||||
return err
|
||||
})
|
||||
return plain, err
|
||||
}
|
||||
|
||||
// RevokeUserLoginKey CLI 撤销未使用登录 Key。
|
||||
func RevokeUserLoginKey(storageDSN, dataDir, id string, openExisting bool) error {
|
||||
return withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
return st.RevokeLoginKey(id)
|
||||
})
|
||||
}
|
||||
|
||||
// BootstrapAdmin 首位管理员引导:单事务串行(KTD12——PG 咨询锁/SQLite 插入后复查),
|
||||
// 建号、激活 Key 与审计共同提交;库内已有用户即拒绝接管。
|
||||
func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (string, error) {
|
||||
var plain string
|
||||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
outc, err := st.BootstrapFirstAdminWrite(store.AccountWrite{
|
||||
Owner: owner, Name: owner, Actor: cliActor(), InstanceID: accountInstanceID(),
|
||||
})
|
||||
plain = outc.Plain
|
||||
return err
|
||||
})
|
||||
return plain, err
|
||||
}
|
||||
|
||||
// ResetUserPassword CLI 重置密码:单事务写 must_change=1、bump 认证版本、
|
||||
// 作废未用登录 Key、吊销全部会话并审计。
|
||||
func ResetUserPassword(storageDSN, dataDir, owner, password string, openExisting bool) (string, error) {
|
||||
if password == "" {
|
||||
password = server.RandomPassword()
|
||||
}
|
||||
hash, err := store.HashPassword(password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
return st.AdminResetUserPassword(owner, hash, cliActor(), "cli")
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -739,9 +837,9 @@ func ResetUserPassword(storageDSN, dataDir, owner, password string) (string, err
|
||||
}
|
||||
|
||||
// ListUserAccounts CLI 用户列表。
|
||||
func ListUserAccounts(storageDSN, dataDir string) ([]store.AdminUser, error) {
|
||||
func ListUserAccounts(storageDSN, dataDir string, openExisting bool) ([]store.AdminUser, error) {
|
||||
var out []store.AdminUser
|
||||
err := withStore(storageDSN, dataDir, func(st *store.Store) error {
|
||||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
var e error
|
||||
out, e = st.ListUsersAdmin()
|
||||
return e
|
||||
@@ -750,13 +848,12 @@ func ListUserAccounts(storageDSN, dataDir string) ([]store.AdminUser, error) {
|
||||
}
|
||||
|
||||
func withStore(storageDSN, dataDir string, fn func(*store.Store) error) error {
|
||||
var st *store.Store
|
||||
var err error
|
||||
if storageDSN != "" {
|
||||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
} else {
|
||||
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||||
}
|
||||
return withStoreMode(storageDSN, dataDir, false, fn)
|
||||
}
|
||||
|
||||
// withIdentityStore 只打开既有库:不建文件、不建目录、不迁移。
|
||||
func withIdentityStore(storageDSN, dataDir string, fn func(*store.Store) error) error {
|
||||
st, err := store.OpenIdentity(storageDSN, dataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -764,55 +861,109 @@ func withStore(storageDSN, dataDir string, fn func(*store.Store) error) error {
|
||||
return fn(st)
|
||||
}
|
||||
|
||||
// CreateAPIKey 是 apikey 子命令的实现:在指定存储档上为 owner 创建密钥(明文仅打印一次)。
|
||||
func CreateAPIKey(storageDSN, dataDir, owner, name string) (string, error) {
|
||||
func withStoreMode(storageDSN, dataDir string, _ bool, fn func(*store.Store) error) error {
|
||||
var st *store.Store
|
||||
var err error
|
||||
if storageDSN != "" {
|
||||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
} else {
|
||||
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||||
// DSN 等价安全输入(KTD14):argv 缺省时回落环境变量——凭据不落 shell history。
|
||||
if storageDSN == "" {
|
||||
storageDSN = os.Getenv("AGENT_SYSTEM_DSN")
|
||||
}
|
||||
// Account maintenance never initializes or migrates storage; the retained
|
||||
// CLI compatibility flag cannot opt this operation into service initialization.
|
||||
if storageDSN != "" {
|
||||
st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
} else {
|
||||
st, err = store.OpenExistingSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
return st.CreateAPIKey(owner, name)
|
||||
if id := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")); id != "" {
|
||||
dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF"))
|
||||
if dbref == "" {
|
||||
if storageDSN != "" {
|
||||
dbref = "pg"
|
||||
} else {
|
||||
dbref = "sqlite"
|
||||
}
|
||||
}
|
||||
if err := st.VerifySystemIdentity(id, dbref); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fn(st)
|
||||
}
|
||||
|
||||
// CreateAPIKey 是 apikey 子命令的实现:在指定存储档上为 owner 创建密钥(明文仅打印一次)。
|
||||
func CreateAPIKey(storageDSN, dataDir, owner, name string) (string, error) {
|
||||
var plain string
|
||||
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
|
||||
p, e := st.CreateAPIKey(owner, name)
|
||||
plain = p
|
||||
return e
|
||||
})
|
||||
return plain, err
|
||||
}
|
||||
|
||||
// ListAPIKeys 列出租户密钥(摘要视图)。
|
||||
func ListAPIKeys(storageDSN, dataDir, owner string) ([]store.APIKeyInfo, error) {
|
||||
var st *store.Store
|
||||
var err error
|
||||
if storageDSN != "" {
|
||||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
} else {
|
||||
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer st.Close()
|
||||
if _, err := st.GetUser(owner); err != nil {
|
||||
return nil, fmt.Errorf("owner %q 不存在(先 apikey create 创建)", owner)
|
||||
}
|
||||
return st.ListAPIKeys(owner)
|
||||
var out []store.APIKeyInfo
|
||||
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
|
||||
if _, err := st.GetUser(owner); err != nil {
|
||||
return fmt.Errorf("owner %q 不存在(先 apikey create 创建)", owner)
|
||||
}
|
||||
var e error
|
||||
out, e = st.ListAPIKeys(owner)
|
||||
return e
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// RevokeAPIKey 撤销指定名称密钥。
|
||||
func RevokeAPIKey(storageDSN, dataDir, owner, name string) (bool, error) {
|
||||
var st *store.Store
|
||||
var err error
|
||||
if storageDSN != "" {
|
||||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
} else {
|
||||
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer st.Close()
|
||||
return st.RevokeAPIKeyByName(owner, name)
|
||||
var ok bool
|
||||
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
|
||||
var e error
|
||||
ok, e = st.RevokeAPIKeyByName(owner, name)
|
||||
return e
|
||||
})
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// EnsureSystemIdentity CLI:显式建立库内服务身份(svc init / I2→I3 引导)。
|
||||
// 只打开既有库,不建库、不迁移。
|
||||
func EnsureSystemIdentity(storageDSN, dataDir, instanceID, dbref string) error {
|
||||
return withIdentityStore(storageDSN, dataDir, func(st *store.Store) error {
|
||||
return st.EnsureSystemIdentity(instanceID, dbref)
|
||||
})
|
||||
}
|
||||
|
||||
// VerifySystemIdentity CLI:校验库内服务身份(svc start 前;失败不插入)。
|
||||
// 只打开既有库,不建库、不迁移。schemaVersion 取身份行上的值。
|
||||
func VerifySystemIdentity(storageDSN, dataDir, instanceID, dbref string) (int, error) {
|
||||
var schemaVersion int
|
||||
err := withIdentityStore(storageDSN, dataDir, func(st *store.Store) error {
|
||||
if err := st.VerifySystemIdentity(instanceID, dbref); err != nil {
|
||||
return err
|
||||
}
|
||||
row, err := st.GetSystemIdentity()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
schemaVersion = row.SchemaVersion
|
||||
return nil
|
||||
})
|
||||
return schemaVersion, err
|
||||
}
|
||||
|
||||
func cliActor() string {
|
||||
return fmt.Sprintf("%d", os.Getuid())
|
||||
}
|
||||
|
||||
func accountInstanceID() string {
|
||||
return strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID"))
|
||||
}
|
||||
|
||||
// cachedInt 带短 TTL 的整型缓存读(限流值热读取免打库)。
|
||||
|
||||
@@ -107,6 +107,11 @@ func (r compiledRegistry) AddCommand(spec json.RawMessage) error {
|
||||
}
|
||||
|
||||
// RegisterResourceAuthorizer 声明式扩展无宿主进程内代码,不能提供回调(fail-closed)。
|
||||
// RegisterResourceAuthorizerV2 声明式扩展同样不能提供资源级回调(fail-closed)。
|
||||
func (r compiledRegistry) RegisterResourceAuthorizerV2(extension.ResourceAuthorizerV2) error {
|
||||
return errors.New("extension: declarative plugins cannot register resource authorizers")
|
||||
}
|
||||
|
||||
func (r compiledRegistry) RegisterResourceAuthorizer(extension.ResourceAuthorizer) error {
|
||||
return errors.New("extension: declarative plugins cannot register resource authorizers")
|
||||
}
|
||||
@@ -124,6 +129,15 @@ func (r pluginRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthoriz
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterResourceAuthorizerV2 进程内扩展:填充 EXT-8b 资源级适配器槽(首注册生效)。
|
||||
func (r pluginRegistry) RegisterResourceAuthorizerV2(fn extension.ResourceAuthorizerV2) error {
|
||||
if fn == nil {
|
||||
return errors.New("extension: " + r.pluginID + ": nil resource authorizer v2")
|
||||
}
|
||||
r.caps.authz.setV2(r.pluginID, fn)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r compiledRegistry) Note(kind, name string, payload json.RawMessage) error {
|
||||
log.Printf("extension: note %s %s (%d bytes)", kind, name, len(payload))
|
||||
return nil
|
||||
|
||||
+158
-11
@@ -4,6 +4,8 @@ import (
|
||||
"agent-system-backend/pkg/extension"
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"agent-system-backend/internal/ingest"
|
||||
@@ -19,6 +21,7 @@ import (
|
||||
// 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内
|
||||
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
|
||||
type hostcaps struct {
|
||||
projects *projectLifecycleSlot
|
||||
st *store.Store
|
||||
pipe *rag.Pipeline
|
||||
set *settings.Service
|
||||
@@ -28,9 +31,11 @@ type hostcaps struct {
|
||||
|
||||
// projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。
|
||||
type projectAuthz struct {
|
||||
mu sync.Mutex
|
||||
holder string // 持有者 pluginID(首个注册者生效)
|
||||
fn extension.ResourceAuthorizer
|
||||
mu sync.Mutex
|
||||
holder string // 持有者 pluginID(首个注册者生效)
|
||||
fn extension.ResourceAuthorizer
|
||||
holder2 string // EXT-8b 资源级持有者(首个注册者生效)
|
||||
fn2 extension.ResourceAuthorizerV2
|
||||
}
|
||||
|
||||
func (a *projectAuthz) set(pluginID string, fn extension.ResourceAuthorizer) {
|
||||
@@ -42,7 +47,41 @@ func (a *projectAuthz) set(pluginID string, fn extension.ResourceAuthorizer) {
|
||||
a.holder, a.fn = pluginID, fn
|
||||
}
|
||||
|
||||
func (a *projectAuthz) setV2(pluginID string, fn extension.ResourceAuthorizerV2) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.fn2 != nil {
|
||||
return
|
||||
}
|
||||
a.holder2, a.fn2 = pluginID, fn
|
||||
}
|
||||
|
||||
// AuthorizeResource EXT-8b 资源级判定(U9):V2 注册时按查询六要素咨询(含
|
||||
// owner 自有命中与共享读取,fail-closed);未注册回落 V1 的 owner×project
|
||||
// 布尔(兼容现行行为)。查询字段由执行端受信构造。
|
||||
func (a *projectAuthz) AuthorizeResource(q extension.ResourceAuthzQuery) (bool, error) {
|
||||
a.mu.Lock()
|
||||
fn2, fn1 := a.fn2, a.fn
|
||||
a.mu.Unlock()
|
||||
if fn2 != nil {
|
||||
if q.Actor == "" || q.Action == "" || q.ResourceType == "" || q.ResourceID == "" {
|
||||
return false, nil // 受信上下文字段缺失=不可判定=拒绝
|
||||
}
|
||||
return fn2(q)
|
||||
}
|
||||
if fn1 == nil || q.Owner == "" || q.ProjectID == "" {
|
||||
return false, nil
|
||||
}
|
||||
return fn1(q.Actor, q.ProjectID)
|
||||
}
|
||||
|
||||
// Authorize owner×project 共享读取判定;未注册一律 false(默认拒绝)。
|
||||
func (a *projectAuthz) hasV2() bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.fn2 != nil
|
||||
}
|
||||
|
||||
func (a *projectAuthz) Authorize(owner string, projectID core.ID) (bool, error) {
|
||||
a.mu.Lock()
|
||||
fn := a.fn
|
||||
@@ -71,6 +110,19 @@ type sharedRetriever interface {
|
||||
|
||||
func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) {
|
||||
q := core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK}
|
||||
if c.projects != nil && projectID != "" {
|
||||
id, err := c.projects.resolve(ctx, c, owner, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q.ProjectID = id
|
||||
}
|
||||
if c.authz != nil && c.authz.hasV2() {
|
||||
return c.pipe.RetrieveAuthorized(ctx, core.ID(owner), q, func(d core.KnowledgeDoc) bool {
|
||||
ok, err := c.authz.AuthorizeResource(extension.ResourceAuthzQuery{Actor: owner, Action: "search", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source})
|
||||
return err == nil && ok
|
||||
})
|
||||
}
|
||||
// EXT-13:进程内 Host 检索面与回合内检索同一授权口径——适配器已注册时走
|
||||
// 共享通道(本人两路如常 + 放行的他人项目知识,与 HTTP 检索面一致)。
|
||||
if c.authz != nil {
|
||||
@@ -141,6 +193,75 @@ func (h inprocHost) IsAdmin() bool {
|
||||
return state.IsAdmin
|
||||
}
|
||||
|
||||
// ListAccounts 实现 EXT-19 AccountLister(最小账号目录):仅授权管理读面用。
|
||||
// 有效态=账号存在且非停用(当前 users 表无停用位——must_change_password 不影响
|
||||
// 有效性;后续加停用位在此映射)。P3(2026-10-13):携带 Name 供同名区分。
|
||||
// 游标=owner 升序偏移。
|
||||
func (h inprocHost) ListAccounts(cursor string, limit int) ([]extension.AccountEntry, string, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
users, err := h.caps.st.ListUsersAdmin()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
entries := make([]extension.AccountEntry, 0, len(users))
|
||||
for _, u := range users {
|
||||
entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: true})
|
||||
}
|
||||
// 排序+游标(owner 升序;cursor 为上一页末 owner,空=首页)。
|
||||
for i := 0; i < len(entries); i++ {
|
||||
for j := i + 1; j < len(entries); j++ {
|
||||
if entries[j].Owner < entries[i].Owner {
|
||||
entries[i], entries[j] = entries[j], entries[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
start := 0
|
||||
if cursor != "" {
|
||||
for start < len(entries) && entries[start].Owner <= cursor {
|
||||
start++
|
||||
}
|
||||
}
|
||||
end := start + limit
|
||||
if end > len(entries) {
|
||||
end = len(entries)
|
||||
}
|
||||
next := ""
|
||||
if end < len(entries) {
|
||||
next = entries[end-1].Owner
|
||||
}
|
||||
return entries[start:end], next, nil
|
||||
}
|
||||
|
||||
// SearchAccounts 实现 EXT-19 扩展 AccountSearcher(P3,2026-10-13):账号或
|
||||
// 姓名子串检索(大小写不敏感;空 query = 首页候选)。结果按 owner 升序,
|
||||
// 有效态口径与 ListAccounts 一致。
|
||||
func (h inprocHost) SearchAccounts(query string, limit int) ([]extension.AccountEntry, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
users, err := h.caps.st.ListUsersAdmin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := strings.ToLower(strings.TrimSpace(query))
|
||||
entries := make([]extension.AccountEntry, 0, len(users))
|
||||
for _, u := range users {
|
||||
if q != "" &&
|
||||
!strings.Contains(strings.ToLower(u.Owner), q) &&
|
||||
!strings.Contains(strings.ToLower(u.Name), q) {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: true})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Owner < entries[j].Owner })
|
||||
if len(entries) > limit {
|
||||
entries = entries[:limit]
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (h inprocHost) KVPut(key, value string) error {
|
||||
return h.caps.st.PutPluginKV(h.pluginID, h.owner, key, value)
|
||||
}
|
||||
@@ -164,6 +285,20 @@ func (h inprocHost) KnowledgeSearch(ctx context.Context, projectID core.ID, text
|
||||
return h.caps.search(ctx, h.owner, projectID, text, topK)
|
||||
}
|
||||
|
||||
// knowledgeSource preserves ordinary host Source semantics. A lifecycle provider
|
||||
// introduces shared publication provenance, so plugins may only assert their own
|
||||
// namespace; another plugin cannot impersonate an RDM published row.
|
||||
func (h inprocHost) knowledgeSource(source string) error {
|
||||
if h.caps.projects == nil || !h.caps.projects.active() {
|
||||
return nil
|
||||
}
|
||||
prefix := "plugin:" + h.pluginID
|
||||
if source == prefix || strings.HasPrefix(source, prefix+":") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("extension: knowledge source must belong to plugin %q", h.pluginID)
|
||||
}
|
||||
|
||||
// KnowledgeWrite 强制 owner 归属:Owner 改写为本次分发 owner,Source 为空补
|
||||
// "plugin:<id>";embed=false 只写全文行,embed=true 走 IndexChunks 建向量
|
||||
// (embedding 失败降级纯全文,不阻断写入)。
|
||||
@@ -177,6 +312,9 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc
|
||||
if docs[i].Source == "" {
|
||||
docs[i].Source = src
|
||||
}
|
||||
if err := h.knowledgeSource(docs[i].Source); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !embed {
|
||||
return h.caps.st.AddKnowledge(docs...)
|
||||
@@ -184,31 +322,40 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc
|
||||
return h.caps.pipe.IndexChunks(ctx, core.ID(h.owner), docs)
|
||||
}
|
||||
|
||||
// KnowledgePublish 幂等发布(EXT-9):按 docs 的 ref 整批替换——先清该 owner
|
||||
// 下同 ref 旧行与旧向量(跨来源也清:ref 是发布单元),再走 KnowledgeWrite
|
||||
// 语义(Owner/Source 强制同彼)。重试产生相同结果。
|
||||
// KnowledgePublish replaces refs and their vectors within the publication scope.
|
||||
// A registered lifecycle scopes replacement to the exact owner/project/plugin;
|
||||
// ordinary unregistered Hosts retain the legacy cross-source ref semantics.
|
||||
func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error {
|
||||
if len(docs) == 0 {
|
||||
return nil
|
||||
}
|
||||
refs := map[string]bool{}
|
||||
type publicationRef struct{ projectID, ref string }
|
||||
refs := map[publicationRef]bool{}
|
||||
for i := range docs {
|
||||
docs[i].Owner = core.ID(h.owner)
|
||||
if docs[i].Source == "" {
|
||||
docs[i].Source = "plugin:" + h.pluginID
|
||||
}
|
||||
if err := h.knowledgeSource(docs[i].Source); err != nil {
|
||||
return err
|
||||
}
|
||||
if docs[i].Ref != "" {
|
||||
refs[docs[i].Ref] = true
|
||||
refs[publicationRef{docs[i].ProjectID, docs[i].Ref}] = true
|
||||
}
|
||||
}
|
||||
scoped := h.caps.projects != nil && h.caps.projects.active()
|
||||
for ref := range refs {
|
||||
if err := h.caps.st.DeleteKnowledgeByRef(h.owner, ref); err != nil {
|
||||
return err
|
||||
var err error
|
||||
if scoped {
|
||||
err = h.caps.st.DeletePluginProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref, h.pluginID)
|
||||
} else {
|
||||
err = h.caps.st.DeleteProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref)
|
||||
}
|
||||
if err := h.caps.st.DeleteKnowledgeVectors(ctx, h.owner, ref); err != nil {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return h.KnowledgeWrite(ctx, docs, embed)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/rag"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHostKnowledgeV2AndProjectReplacement(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
pipe := rag.NewPipeline(st, settings.NewService(st), nil, nil)
|
||||
auth := &projectAuthz{}
|
||||
alice := inprocHost{owner: "alice", pluginID: "rdm", caps: hostcaps{st: st, pipe: pipe, authz: auth}}
|
||||
first := []core.KnowledgeDoc{{ID: "slice-1", ProjectID: "domain-1", Ref: "notes.md", Chunk: "sharedneedle one"}}
|
||||
second := []core.KnowledgeDoc{{ID: "slice-2", ProjectID: "domain-2", Ref: "notes.md", Chunk: "sharedneedle two"}}
|
||||
for _, ds := range [][]core.KnowledgeDoc{first, second, second} {
|
||||
if err := alice.KnowledgePublish(context.Background(), ds, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
own, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "sharedneedle"})
|
||||
if err != nil || len(own) != 2 {
|
||||
t.Fatalf("cross project publish replaced first: %+v %v", own, err)
|
||||
}
|
||||
auth.set("rdm", func(string, core.ID) (bool, error) { return true, nil })
|
||||
auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) {
|
||||
return q.ResourceType == "project" || (q.ResourceType == "knowledge" && q.ResourceID == "slice-1" && q.ProjectID == "domain-1" && q.Actor == "bob" && q.Owner == "alice"), nil
|
||||
})
|
||||
bob := inprocHost{owner: "bob", pluginID: "rdm", caps: alice.caps}
|
||||
hits, err := bob.KnowledgeSearch(context.Background(), "", "sharedneedle", 5)
|
||||
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-1" {
|
||||
t.Fatalf("host V2=%+v %v", hits, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostKnowledgeResolvesTrustedProjectScope(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "alice", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "alice", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slot := &projectLifecycleSlot{}
|
||||
provider := completeLifecycle()
|
||||
provider.List = func(_ context.Context, h extension.Host) ([]core.Project, error) {
|
||||
if h.Owner() != "bob" {
|
||||
t.Fatalf("wrong scope actor %q", h.Owner())
|
||||
}
|
||||
return []core.Project{{ID: "host-current", Settings: map[string]any{"rdm.projectId": "domain-current", "rdm.state": "ready"}}}, nil
|
||||
}
|
||||
caps := hostcaps{st: st, pipe: rag.NewPipeline(st, settings.NewService(st), nil, nil), authz: &projectAuthz{}, projects: slot}
|
||||
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caps.authz.setV2("rdm", func(extension.ResourceAuthzQuery) (bool, error) { return true, nil })
|
||||
h := inprocHost{owner: "bob", pluginID: "rdm", caps: caps}
|
||||
hits, err := h.KnowledgeSearch(context.Background(), "host-current", "scopeneedle", 5)
|
||||
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-current" {
|
||||
t.Fatalf("host scope: %+v %v", hits, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisteredProjectHostRejectsForeignKnowledgeSource(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
slot := &projectLifecycleSlot{}
|
||||
caps := hostcaps{st: st, projects: slot}
|
||||
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := inprocHost{owner: "alice", pluginID: "other", caps: caps}
|
||||
for _, publish := range []bool{false, true} {
|
||||
docs := []core.KnowledgeDoc{{ID: "claimed-published", ProjectID: "domain", Source: "plugin:rdm:published", Ref: "ref", Chunk: "spoofneedle"}}
|
||||
var err error
|
||||
if publish {
|
||||
err = h.KnowledgePublish(context.Background(), docs, false)
|
||||
} else {
|
||||
err = h.KnowledgeWrite(context.Background(), docs, false)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("foreign namespace accepted publish=%v", publish)
|
||||
}
|
||||
}
|
||||
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "spoofneedle"}); err != nil || len(hits) != 0 {
|
||||
t.Fatalf("foreign source wrote data: %+v %v", hits, err)
|
||||
}
|
||||
// Existing ordinary Hosts preserve their documented caller-provided Source.
|
||||
h.caps.projects = nil
|
||||
if err := h.KnowledgeWrite(context.Background(), []core.KnowledgeDoc{{Source: "manual", Chunk: "ordinaryneedle"}}, false); err != nil {
|
||||
t.Fatalf("ordinary source semantics changed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPublishPreservesForeignPluginAndScope(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
slot := &projectLifecycleSlot{}
|
||||
caps := hostcaps{st: st, projects: slot}
|
||||
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := core.KnowledgeDoc{ID: "rdm-published", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Ref: "notes.md", Chunk: "originalneedle"}
|
||||
if err := st.AddKnowledge(original); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.UpsertKnowledgeVector(context.Background(), "alice", original.ID, original.Ref, []float32{1, 0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := inprocHost{owner: "alice", pluginID: "other", caps: caps}
|
||||
replacement := []core.KnowledgeDoc{{ID: "other", ProjectID: "domain", Source: "plugin:other", Ref: "notes.md", Chunk: "replacementneedle"}}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := h.KnowledgePublish(context.Background(), replacement, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "originalneedle"}); err != nil || len(hits) != 1 {
|
||||
t.Fatalf("foreign plugin deleted RDM publication: %+v %v", hits, err)
|
||||
}
|
||||
if hits, err := st.SearchKnowledgeVectors(context.Background(), "alice", []float32{1, 0}, 5, core.RetrievalQuery{ProjectID: "domain"}); err != nil || len(hits) != 1 || hits[0].Doc.ID != original.ID {
|
||||
t.Fatalf("foreign plugin deleted RDM vector: %+v %v", hits, err)
|
||||
}
|
||||
h.pluginID = "rdm"
|
||||
if err := h.KnowledgePublish(context.Background(), []core.KnowledgeDoc{{ID: "unscoped", Source: "plugin:rdm", Ref: "notes.md", Chunk: "privateneedle"}}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "originalneedle", ProjectID: "domain"}); err != nil || len(hits) != 1 {
|
||||
t.Fatalf("unscoped publication deleted scoped RDM data: %+v %v", hits, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnregisteredPublishRetainsLegacyRefScope(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "legacy", Owner: "alice", ProjectID: "old-project", Source: "manual", Ref: "same", Chunk: "legacyneedle"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := inprocHost{owner: "alice", pluginID: "other", caps: hostcaps{st: st}}
|
||||
if err := h.KnowledgePublish(context.Background(), []core.KnowledgeDoc{{ID: "replacement", Source: "manual", Ref: "same", Chunk: "replacementneedle"}}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "legacyneedle"}); err != nil || len(hits) != 0 {
|
||||
t.Fatalf("unregistered legacy scope changed: %+v %v", hits, err)
|
||||
}
|
||||
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "replacementneedle"}); err != nil || len(hits) != 1 {
|
||||
t.Fatalf("unregistered replacement missing: %+v %v", hits, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/projectscope"
|
||||
"agent-system-backend/internal/server"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type projectLifecycleSlot struct {
|
||||
mu sync.Mutex
|
||||
holder string
|
||||
provider *extension.ProjectLifecycle
|
||||
}
|
||||
|
||||
func (r compiledRegistry) RegisterProjectLifecycle(extension.ProjectLifecycle) error {
|
||||
return errors.New("extension: declarative plugins cannot register project lifecycle")
|
||||
}
|
||||
func (r pluginRegistry) RegisterProjectLifecycle(p extension.ProjectLifecycle) error {
|
||||
if r.caps.projects == nil || r.pluginID == "" {
|
||||
return errors.New("extension: project lifecycle slot unavailable")
|
||||
}
|
||||
if p.List == nil || p.Create == nil || p.Rename == nil || p.Archive == nil || p.Work == nil {
|
||||
return errors.New("extension: project lifecycle requires all callbacks")
|
||||
}
|
||||
slot := r.caps.projects
|
||||
slot.mu.Lock()
|
||||
defer slot.mu.Unlock()
|
||||
if slot.provider != nil {
|
||||
return errors.New("extension: project lifecycle already registered")
|
||||
}
|
||||
slot.holder, slot.provider = r.pluginID, &p
|
||||
return nil
|
||||
}
|
||||
func (r recordRegistry) RegisterProjectLifecycle(p extension.ProjectLifecycle) error {
|
||||
reg, ok := r.Registry.(extension.ProjectLifecycleRegistry)
|
||||
if !ok {
|
||||
return errors.New("extension: project lifecycle registration unavailable")
|
||||
}
|
||||
return reg.RegisterProjectLifecycle(p)
|
||||
}
|
||||
func (slot *projectLifecycleSlot) wire(srv *server.Server, caps hostcaps) {
|
||||
slot.mu.Lock()
|
||||
defer slot.mu.Unlock()
|
||||
if slot.provider == nil {
|
||||
return
|
||||
}
|
||||
holder := slot.holder
|
||||
srv.SetProjectLifecycle(*slot.provider, func(owner string) extension.Host {
|
||||
return inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder}
|
||||
})
|
||||
}
|
||||
|
||||
func (slot *projectLifecycleSlot) active() bool {
|
||||
slot.mu.Lock()
|
||||
defer slot.mu.Unlock()
|
||||
return slot.provider != nil
|
||||
}
|
||||
|
||||
func (slot *projectLifecycleSlot) work() func(context.Context, string, core.ID) (bool, error) {
|
||||
slot.mu.Lock()
|
||||
defer slot.mu.Unlock()
|
||||
if slot.provider == nil {
|
||||
return nil
|
||||
}
|
||||
return slot.provider.Work
|
||||
}
|
||||
|
||||
func (slot *projectLifecycleSlot) resolve(ctx context.Context, caps hostcaps, owner string, id core.ID) (core.ID, error) {
|
||||
slot.mu.Lock()
|
||||
if slot.provider == nil {
|
||||
slot.mu.Unlock()
|
||||
return id, nil
|
||||
}
|
||||
p, holder := *slot.provider, slot.holder
|
||||
slot.mu.Unlock()
|
||||
return projectscope.Resolve(ctx, inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder}, p, id)
|
||||
}
|
||||
|
||||
var _ extension.ProjectHost = inprocHost{}
|
||||
|
||||
func (h inprocHost) OwnedProjects() ([]core.Project, error) {
|
||||
ps, err := h.caps.st.ListProjects(h.owner)
|
||||
for i := range ps {
|
||||
ps[i].Owner = h.owner
|
||||
}
|
||||
return ps, err
|
||||
}
|
||||
func (h inprocHost) OwnedProject(id string) (core.Project, error) {
|
||||
return h.caps.st.GetProject(h.owner, id)
|
||||
}
|
||||
func (h inprocHost) ProjectMetadata(id string) (core.Project, error) {
|
||||
p, err := h.caps.st.ProjectMetadata(id)
|
||||
if err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
if h.caps.projects != nil && h.caps.projects.active() {
|
||||
p.Settings = h.caps.set.MaskedProjectMetadata(p.Settings)
|
||||
}
|
||||
if p.Owner == h.owner {
|
||||
return p, nil
|
||||
}
|
||||
if h.caps.authz == nil {
|
||||
return core.Project{}, store.ErrNotFound
|
||||
}
|
||||
ok, err := h.caps.authz.AuthorizeResource(extension.ResourceAuthzQuery{Actor: h.owner, Action: "read", ResourceType: "project", ResourceID: core.ID(id), Owner: p.Owner, ProjectID: core.ID(id)})
|
||||
if err != nil || !ok {
|
||||
return core.Project{}, store.ErrNotFound
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
func (h inprocHost) EnsureProject(id, name string) (core.Project, error) {
|
||||
return h.caps.st.EnsureProject(h.owner, id, name)
|
||||
}
|
||||
func (h inprocHost) RenameOwnedProject(id, name string) (core.Project, error) {
|
||||
return h.caps.st.RenameOwnedProject(h.owner, id, name)
|
||||
}
|
||||
func (h inprocHost) ProjectNameAvailable(name, exceptID string) (bool, error) {
|
||||
return h.caps.st.ProjectNameAvailable(name, exceptID)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/server"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func completeLifecycle() extension.ProjectLifecycle {
|
||||
return extension.ProjectLifecycle{
|
||||
List: func(context.Context, extension.Host) ([]core.Project, error) { return nil, nil },
|
||||
Create: func(context.Context, extension.Host, string) (core.Project, error) { return core.Project{}, nil },
|
||||
Rename: func(context.Context, extension.Host, string, string) (core.Project, error) {
|
||||
return core.Project{}, nil
|
||||
},
|
||||
Archive: func(context.Context, extension.Host, string) error { return nil },
|
||||
Work: func(context.Context, string, core.ID) (bool, error) { return true, nil },
|
||||
}
|
||||
}
|
||||
func TestProjectLifecycleOptionalRegistration(t *testing.T) {
|
||||
slot := &projectLifecycleSlot{}
|
||||
reg := pluginRegistry{pluginID: "rdm", caps: hostcaps{projects: slot}}
|
||||
if err := reg.RegisterProjectLifecycle(extension.ProjectLifecycle{}); err == nil {
|
||||
t.Fatal("incomplete registration accepted")
|
||||
}
|
||||
if err := reg.RegisterProjectLifecycle(completeLifecycle()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := reg.RegisterProjectLifecycle(completeLifecycle()); err == nil {
|
||||
t.Fatal("duplicate registration accepted")
|
||||
}
|
||||
if err := (compiledRegistry{}).RegisterProjectLifecycle(completeLifecycle()); err == nil {
|
||||
t.Fatal("declarative callback accepted")
|
||||
}
|
||||
rr := recordRegistry{Registry: pluginRegistry{pluginID: "other", caps: hostcaps{projects: &projectLifecycleSlot{}}}}
|
||||
optional, ok := any(rr).(extension.ProjectLifecycleRegistry)
|
||||
if !ok {
|
||||
t.Fatal("record registry hides optional registration")
|
||||
}
|
||||
if err := optional.RegisterProjectLifecycle(completeLifecycle()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestProjectHostMetadataDoesNotGrantMutation(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
alice := inprocHost{owner: "alice", caps: hostcaps{st: st}}
|
||||
p, err := alice.EnsureProject("stable", "Shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth := &projectAuthz{}
|
||||
auth.set("rdm", func(owner string, id core.ID) (bool, error) { return owner == "bob" && id == core.ID(p.ID), nil })
|
||||
bob := inprocHost{owner: "bob", caps: hostcaps{st: st, authz: auth}}
|
||||
if _, err := bob.ProjectMetadata(p.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := bob.OwnedProject(p.ID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("owned lookup=%v", err)
|
||||
}
|
||||
if _, err := bob.RenameOwnedProject(p.ID, "Other"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("shared rename=%v", err)
|
||||
}
|
||||
if _, err := bob.EnsureProject(p.ID, p.Name); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("shared ensure=%v", err)
|
||||
}
|
||||
auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) { return false, nil })
|
||||
if _, err := bob.ProjectMetadata(p.ID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("V2 denial ignored=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectHostMetadataMasksSharedSecretsWithoutMutation(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
p, err := st.CreateProject("alice", "Shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "fixture-nested-secret"}}, "models.embedding.apiKey": "fixture-flat-secret", "legacy.token": "fixture-legacy-secret", "plugins.private": map[string]any{"credential": "fixture-schema-secret"}, "display": map[string]any{"caption": "Shared configuration"}, "rdm.projectId": "canonical", "rdm.state": "ready"}
|
||||
if err := st.UpdateProjectSettings("alice", p.ID, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := settings.NewService(st)
|
||||
svc.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.credential", Secret: true}})
|
||||
slot := &projectLifecycleSlot{}
|
||||
auth := &projectAuthz{}
|
||||
caps := hostcaps{st: st, set: svc, projects: slot, authz: auth}
|
||||
provider := completeLifecycle()
|
||||
provider.List = func(_ context.Context, h extension.Host) ([]core.Project, error) {
|
||||
project, err := h.(extension.ProjectHost).ProjectMetadata(p.ID)
|
||||
return []core.Project{project}, err
|
||||
}
|
||||
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) {
|
||||
return q.Actor == "bob" && q.ResourceType == "project" && q.ResourceID == p.ID, nil
|
||||
})
|
||||
for _, actor := range []string{"bob", "alice"} {
|
||||
h := inprocHost{owner: actor, caps: caps}
|
||||
view, err := h.ProjectMetadata(p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded, _ := json.Marshal(view.Settings)
|
||||
for _, value := range []string{"fixture-nested-secret", "fixture-flat-secret", "fixture-legacy-secret", "fixture-schema-secret"} {
|
||||
if bytes.Contains(encoded, []byte(value)) {
|
||||
t.Fatalf("%s metadata exposed a fixture credential", actor)
|
||||
}
|
||||
}
|
||||
if view.Settings["rdm.projectId"] != "canonical" || view.Settings["rdm.state"] != "ready" || view.Settings["display"].(map[string]any)["caption"] != "Shared configuration" {
|
||||
t.Fatal("allowed service/configuration metadata changed")
|
||||
}
|
||||
}
|
||||
// Exercise the real registry -> actor-scoped Host -> public HTTP list chain.
|
||||
srv := server.New(st, nil, nil, nil, svc, nil)
|
||||
srv.Auth = server.NewAuthenticator(st, true)
|
||||
slot.wire(srv, caps)
|
||||
key, err := st.CreateAPIKey("bob", "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest("GET", "/api/projects", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+key)
|
||||
response := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(response, request)
|
||||
if response.Code != 200 {
|
||||
t.Fatalf("member list=%d", response.Code)
|
||||
}
|
||||
for _, value := range []string{"fixture-nested-secret", "fixture-flat-secret", "fixture-legacy-secret", "fixture-schema-secret"} {
|
||||
if bytes.Contains(response.Body.Bytes(), []byte(value)) {
|
||||
t.Fatal("actor-scoped lifecycle list exposed a fixture credential")
|
||||
}
|
||||
}
|
||||
if !bytes.Contains(response.Body.Bytes(), []byte(`"rdm.projectId":"canonical"`)) {
|
||||
t.Fatal("actor-scoped list lost canonical metadata")
|
||||
}
|
||||
|
||||
owned, err := (inprocHost{owner: "alice", caps: caps}).OwnedProject(p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := json.Marshal(raw)
|
||||
after, _ := json.Marshal(owned.Settings)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("metadata read mutated persisted owner settings")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package app
|
||||
|
||||
// EXT-8b 资源级授权槽位测试(U9 首切片):
|
||||
// 1. V2 未注册 → AuthorizeResource 回落 V1(owner×project 兼容现行);
|
||||
// 2. V2 注册 → 按查询六要素咨询;受信字段缺失(actor/action/type/id 空)=拒绝;
|
||||
// 3. V2 返回 error = 拒绝(fail-closed);
|
||||
// 4. 首注册者生效(后来者忽略);
|
||||
// 5. compiledRegistry 的 V2 注册被拒(声明式无宿主回调)。
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/pkg/extension"
|
||||
)
|
||||
|
||||
func TestAuthorizeResourceV2FallbackAndQuery(t *testing.T) {
|
||||
var a projectAuthz
|
||||
// 未注册任何适配器 → 拒绝。
|
||||
if ok, err := a.AuthorizeResource(extension.ResourceAuthzQuery{
|
||||
Actor: "u1", Action: "download", ResourceType: "artifact", ResourceID: "a1",
|
||||
Owner: "u2", ProjectID: "p1"}); ok || err != nil {
|
||||
t.Fatalf("未注册应拒绝, got %v %v", ok, err)
|
||||
}
|
||||
// 仅 V1:回落 owner×project 布尔。
|
||||
a.set("p1", func(owner string, projectID string) (bool, error) { return true, nil })
|
||||
if ok, _ := a.AuthorizeResource(extension.ResourceAuthzQuery{
|
||||
Actor: "u1", Action: "download", ResourceType: "artifact", ResourceID: "a1",
|
||||
Owner: "u2", ProjectID: "p1"}); !ok {
|
||||
t.Fatal("V1 允许时回落应放行")
|
||||
}
|
||||
// V1 路径要求 owner/project 非空。
|
||||
if ok, _ := a.AuthorizeResource(extension.ResourceAuthzQuery{
|
||||
Actor: "u1", Action: "download", ResourceType: "artifact", ResourceID: "a1"}); ok {
|
||||
t.Fatal("V1 回落在无项目私域应拒绝")
|
||||
}
|
||||
// 注册 V2 → 按六要素咨询。
|
||||
a.setV2("p1", func(q extension.ResourceAuthzQuery) (bool, error) {
|
||||
return q.Actor == "u1" && q.Action == "download" && q.ResourceType == "artifact" &&
|
||||
q.ResourceID == "a1" && q.Owner == "u2" && q.ProjectID == "p1", nil
|
||||
})
|
||||
if ok, _ := a.AuthorizeResource(extension.ResourceAuthzQuery{
|
||||
Actor: "u1", Action: "download", ResourceType: "artifact", ResourceID: "a1",
|
||||
Owner: "u2", ProjectID: "p1"}); !ok {
|
||||
t.Fatal("V2 六要素齐备应放行")
|
||||
}
|
||||
// 六要素缺一(action 空)=拒绝(不可判定)。
|
||||
if ok, _ := a.AuthorizeResource(extension.ResourceAuthzQuery{
|
||||
Actor: "u1", ResourceType: "artifact", ResourceID: "a1", Owner: "u2", ProjectID: "p1"}); ok {
|
||||
t.Fatal("受信字段缺失应拒绝")
|
||||
}
|
||||
// V2 出错=拒绝(fail-closed)。
|
||||
a2 := projectAuthz{}
|
||||
a2.setV2("p1", func(extension.ResourceAuthzQuery) (bool, error) { return false, errors.New("boom") })
|
||||
if ok, err := a2.AuthorizeResource(extension.ResourceAuthzQuery{
|
||||
Actor: "u", Action: "read", ResourceType: "x", ResourceID: "1"}); ok || err == nil {
|
||||
t.Fatal("V2 error 应拒绝")
|
||||
}
|
||||
// 首注册者生效。
|
||||
a3 := projectAuthz{}
|
||||
a3.setV2("first", func(extension.ResourceAuthzQuery) (bool, error) { return true, nil })
|
||||
a3.setV2("second", func(extension.ResourceAuthzQuery) (bool, error) { return false, nil })
|
||||
if ok, _ := a3.AuthorizeResource(extension.ResourceAuthzQuery{
|
||||
Actor: "u", Action: "read", ResourceType: "x", ResourceID: "1"}); !ok {
|
||||
t.Fatal("首个 V2 注册者应生效")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompiledRegistryRejectsV2(t *testing.T) {
|
||||
r := compiledRegistry{}
|
||||
if err := r.RegisterResourceAuthorizerV2(func(extension.ResourceAuthzQuery) (bool, error) { return true, nil }); err == nil {
|
||||
t.Fatal("声明式扩展不得注册资源级授权回调(fail-closed)")
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
@@ -19,6 +20,21 @@ type Store interface {
|
||||
Delete(ref string) error
|
||||
}
|
||||
|
||||
// IsNil 判断对象存储是否未配置。nil 接口,以及放进接口后的带类型空指针
|
||||
// (例如未设置端点时的 (*S3)(nil)),都算未配置。已构造的实现返回 false。
|
||||
func IsNil(s Store) bool {
|
||||
if s == nil {
|
||||
return true
|
||||
}
|
||||
v := reflect.ValueOf(s)
|
||||
switch v.Kind() {
|
||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||
return v.IsNil()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Local 是本地目录实现(data/artifacts;多实例时把目录指向共享卷即可)。
|
||||
type Local struct {
|
||||
dir string
|
||||
@@ -32,6 +48,12 @@ func NewLocal(dir string) (*Local, error) {
|
||||
return &Local{dir: dir}, nil
|
||||
}
|
||||
|
||||
// OpenLocalExisting 绑定既有目录,不 MkdirAll。目录缺失时仍返回可用句柄
|
||||
// (账户运维只碰 DB;Put 时若目录不存在会失败)。
|
||||
func OpenLocalExisting(dir string) (*Local, error) {
|
||||
return &Local{dir: dir}, nil
|
||||
}
|
||||
|
||||
// validID 只接受纯文件名形态的对象 id:非空、不含路径分隔符、非 . / ..。
|
||||
// Put 的 id 可能是上层透传的用户输入(如工件路径参数),先在此挡住路径穿越。
|
||||
func validID(id string) bool {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package blobstore
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsNil(t *testing.T) {
|
||||
var unset Store
|
||||
if !IsNil(unset) {
|
||||
t.Fatal("nil interface must count as unset")
|
||||
}
|
||||
var typed *S3
|
||||
var asStore Store = typed
|
||||
if !IsNil(asStore) {
|
||||
t.Fatal("typed nil *S3 must count as unset")
|
||||
}
|
||||
local, err := NewLocal(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if IsNil(local) {
|
||||
t.Fatal("local store must count as configured")
|
||||
}
|
||||
s3, err := NewS3("http://127.0.0.1:9/bucket", "us-east-1", "access", "secret", "art/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if IsNil(s3) {
|
||||
t.Fatal("configured S3 must count as configured")
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,9 @@ var (
|
||||
const cardAppendWindow = 250 * time.Millisecond
|
||||
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
emit Emitter
|
||||
st *store.Store
|
||||
emit Emitter
|
||||
projectSharedEmit ProjectSharedEmitter
|
||||
// MirrorConvCards EXT-12:会话卡是否镜像进项目公共流(缺省 true=现状全量
|
||||
// 镜像;false 时会话卡只进本人会话流,项目级卡不受影响)。
|
||||
MirrorConvCards bool
|
||||
@@ -41,6 +42,42 @@ func New(st *store.Store, emit Emitter) *Service {
|
||||
return &Service{st: st, emit: emit, now: time.Now, lastEmit: map[string]time.Time{}, MirrorConvCards: true}
|
||||
}
|
||||
|
||||
// ProjectSharedEmitter is an internal trusted publication adapter. It is never
|
||||
// bound to model arguments or exposed as a mandatory extension capability.
|
||||
type ProjectSharedEmitter func(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error
|
||||
|
||||
func (s *Service) SetProjectSharedEmitter(emit ProjectSharedEmitter) { s.projectSharedEmit = emit }
|
||||
|
||||
// PublishProjectCard explicitly publishes a project-level structural snapshot.
|
||||
// Ordinary Create/Update remain private unless this entry issues a new proof.
|
||||
func (s *Service) PublishProjectCard(owner, producer string, card core.ActivityCard, ref string) (core.ActivityCard, error) {
|
||||
if s.projectSharedEmit == nil || owner == "" || producer == "" || card.ProjectID == "" || card.ConversationID != "" {
|
||||
return card, errors.New("card: trusted project publisher required")
|
||||
}
|
||||
if card.ID != "" {
|
||||
current, err := s.st.GetCard(owner, card.ID)
|
||||
if err != nil {
|
||||
return card, err
|
||||
}
|
||||
if current.ProjectID != card.ProjectID || current.ConversationID != "" {
|
||||
return card, store.ErrNotFound
|
||||
}
|
||||
} else {
|
||||
card.ID = store.NewID("card")
|
||||
}
|
||||
now := s.now().UTC()
|
||||
card.CreatedAt = now
|
||||
card.UpdatedAt = now
|
||||
if card.Status == "" {
|
||||
card.Status = core.CardActive
|
||||
}
|
||||
if err := s.st.SaveCard(owner, &card, ref); err != nil {
|
||||
return card, err
|
||||
}
|
||||
err := s.projectSharedEmit(owner, producer, string(core.ProjectChannelKey(card.ProjectID)), card.SourceRunID, core.EvCardCreated, core.CardPayload{Card: card})
|
||||
return card, err
|
||||
}
|
||||
|
||||
// emitCard 按卡归属发布卡片事件(D50):
|
||||
// - 会话卡 → 会话流;同时镜像项目流(伪会话键 "project:<projectId>")——
|
||||
// 项目收件箱聚合视图(项目级卡 ∪ 各会话未决卡)的实时性依赖它;卡片事件
|
||||
|
||||
@@ -222,7 +222,11 @@ func (r *fakeRegistry) Note(kind, name string, p json.RawMessage) error { return
|
||||
func (r *fakeRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthorizer) error {
|
||||
return nil
|
||||
}
|
||||
func (r *fakeRegistry) AddIntent(spec json.RawMessage) error { return nil }
|
||||
|
||||
func (r *fakeRegistry) RegisterResourceAuthorizerV2(fn extension.ResourceAuthorizerV2) error {
|
||||
return nil
|
||||
}
|
||||
func (r *fakeRegistry) AddIntent(spec json.RawMessage) error { return nil }
|
||||
func (r *fakeRegistry) AddAdminZone(spec json.RawMessage) error { return nil }
|
||||
|
||||
func TestPresentRejectsUnknownActionIDs(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Package projectscope resolves host retrieval filters using service-provided
|
||||
// lifecycle metadata. Stored/client settings never supply an authority mapping.
|
||||
package projectscope
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
"context"
|
||||
)
|
||||
|
||||
func Resolve(ctx context.Context, host extension.Host, p extension.ProjectLifecycle, id core.ID) (core.ID, error) {
|
||||
if id == "" {
|
||||
return id, nil
|
||||
}
|
||||
if p.List == nil {
|
||||
return "", store.ErrNotFound
|
||||
}
|
||||
projects, err := p.List(ctx, host)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, project := range projects {
|
||||
domainID, _ := project.Settings["rdm.projectId"].(string)
|
||||
if project.ID != id && domainID != id {
|
||||
continue
|
||||
}
|
||||
state, _ := project.Settings["rdm.state"].(string)
|
||||
if domainID == "" || state != "ready" {
|
||||
return "", store.ErrNotFound
|
||||
}
|
||||
return core.ID(domainID), nil
|
||||
}
|
||||
return "", store.ErrNotFound
|
||||
}
|
||||
@@ -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`。
|
||||
|
||||
+52
-4
@@ -174,19 +174,66 @@ func (p *Pipeline) RetrieveShared(ctx context.Context, owner core.ID, q core.Ret
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// RetrieveAuthorized is the resource-level path for HTTP, Host and model
|
||||
// context. It filters owned and shared candidates before external reranking.
|
||||
func (p *Pipeline) RetrieveAuthorized(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) {
|
||||
own, err := p.retrieve(ctx, owner, q, allow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shared, err := p.st.SearchKnowledgeSharedAuthorized(string(owner), q, allow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.embedder != nil {
|
||||
if vectors, e := p.embedder.Embed(ctx, []string{q.Text}); e == nil && len(vectors) > 0 && len(vectors[0]) > 0 {
|
||||
topK := q.TopK
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
vs, e := p.st.SearchKnowledgeVectorsSharedAuthorized(ctx, string(owner), embedderModel(p.embedder), vectors[0], topK, q, allow)
|
||||
if e == nil {
|
||||
shared = rrfMerge(vs, shared)
|
||||
}
|
||||
}
|
||||
}
|
||||
merged := rrfMerge(own, shared)
|
||||
if limit := q.TopK; limit > 0 && len(merged) > limit {
|
||||
merged = merged[:limit]
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
func authorizedHits(hits []core.RetrievalHit, allow func(core.KnowledgeDoc) bool) []core.RetrievalHit {
|
||||
if allow == nil {
|
||||
return hits
|
||||
}
|
||||
out := make([]core.RetrievalHit, 0, len(hits))
|
||||
for _, h := range hits {
|
||||
if allow(h.Doc) {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Retrieve 统一检索(core.Retriever)。
|
||||
func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
return p.retrieve(ctx, owner, q, nil)
|
||||
}
|
||||
func (p *Pipeline) retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) {
|
||||
topK := q.TopK
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
if p.embedder == nil {
|
||||
return p.st.SearchKnowledge(owner, q)
|
||||
hits, err := p.st.SearchKnowledge(owner, q)
|
||||
return authorizedHits(hits, allow), err
|
||||
}
|
||||
route := p.embeddingRoute(ctx)
|
||||
offline, _ := p.embedder.(interface{ Offline() bool })
|
||||
if !route.OK() && (offline == nil || !offline.Offline()) {
|
||||
return p.st.SearchKnowledge(owner, q)
|
||||
hits, err := p.st.SearchKnowledge(owner, q)
|
||||
return authorizedHits(hits, allow), err
|
||||
}
|
||||
qvecs, err := p.embedder.Embed(ctx, []string{q.Text})
|
||||
if err != nil || len(qvecs) == 0 || len(qvecs[0]) == 0 {
|
||||
@@ -194,7 +241,8 @@ func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.Retrieval
|
||||
if err != nil {
|
||||
log.Printf("[rag] query embedding fallback to fulltext: %v", err)
|
||||
}
|
||||
return p.st.SearchKnowledge(owner, q)
|
||||
hits, err := p.st.SearchKnowledge(owner, q)
|
||||
return authorizedHits(hits, allow), err
|
||||
}
|
||||
q.TopK = topK * 2 // 每路多取,融合后再裁剪
|
||||
vhits, err := p.st.SearchKnowledgeVectorsModel(ctx, owner, embedderModel(p.embedder), qvecs[0], topK*2, q)
|
||||
@@ -205,7 +253,7 @@ func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.Retrieval
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
merged := rrfMerge(vhits, fhits)
|
||||
merged := authorizedHits(rrfMerge(vhits, fhits), allow)
|
||||
if p.reranker != nil && p.rerankRoute(ctx).OK() && len(merged) > 0 {
|
||||
docs := make([]core.KnowledgeDoc, len(merged))
|
||||
for i, h := range merged {
|
||||
|
||||
@@ -162,8 +162,9 @@ func TestRetrieveFallbackAndHybrid(t *testing.T) {
|
||||
}
|
||||
|
||||
// 有 embedder 且路由已配置:查询向量 A 类 → A 文档排前(RRF 向量路主导)。
|
||||
// P2 受管:models.* 是系统受管项,配置写入系统层行(owner=OwnerSystemRow)。
|
||||
pipe2, st2 := newTestPipeline(t, fakeEmbedder{}, nil)
|
||||
if err := st2.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{
|
||||
if err := st2.MergeSettingsScope(settings.OwnerSystemRow, settings.ScopeSystem, map[string]any{
|
||||
"models": map[string]any{"embedding": map[string]any{"baseUrl": "http://fake.internal", "model": "e-test"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -120,18 +120,67 @@ func TestBackgroundCardLifecycleOnFailure(t *testing.T) {
|
||||
t.Fatalf("cards = %d, want 1", len(cards))
|
||||
}
|
||||
card := cards[0]
|
||||
if card.Status != core.CardDone || card.Tone != "danger" {
|
||||
t.Fatalf("status = %s tone = %s, want done/danger", card.Status, card.Tone)
|
||||
if card.Status != core.CardFailed || card.Tone != "danger" {
|
||||
t.Fatalf("status = %s tone = %s, want failed/danger", card.Status, card.Tone)
|
||||
}
|
||||
var md string
|
||||
statusLabel := ""
|
||||
for _, n := range card.Body {
|
||||
if n.Type == core.UIMarkdown {
|
||||
md = n.Markdown
|
||||
}
|
||||
if n.Type == core.UIKV {
|
||||
for _, kv := range n.KV {
|
||||
if kv[0] == core.TaskSummaryKVStatus {
|
||||
statusLabel = kv[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.Contains(md, "执行失败") {
|
||||
t.Fatalf("failure body = %q", md)
|
||||
}
|
||||
if statusLabel != core.TaskSummaryStatusFailed {
|
||||
t.Fatalf("status kv = %q, want %q", statusLabel, core.TaskSummaryStatusFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackgroundCardCreateUsesL1SkeletonNotRawTask(t *testing.T) {
|
||||
runner := &holdRunner{release: make(chan struct{})}
|
||||
c, _ := newCardSpawnEnv(t, runner)
|
||||
c.mgr.runner = noopRunner{}
|
||||
raw := "逐页解析上传材料并对照验收条件" + strings.Repeat("LONGTASK", 40)
|
||||
if _, err := c.SpawnBackground("agent.sub.general", "深度解析员", raw, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitUntil(t, func() bool {
|
||||
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
|
||||
return err == nil && len(cards) == 1
|
||||
})
|
||||
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
card := cards[0]
|
||||
var md string
|
||||
for _, n := range card.Body {
|
||||
if n.Type == core.UIMarkdown {
|
||||
md = n.Markdown
|
||||
}
|
||||
}
|
||||
if !strings.Contains(md, "执行失败") {
|
||||
t.Fatalf("failure body = %q", md)
|
||||
if strings.Contains(md, "LONGTASK") || strings.Contains(md, raw[:32]) {
|
||||
t.Fatalf("create body still embeds raw task: %q", md)
|
||||
}
|
||||
if md != core.TaskSummaryNoFindingsYet {
|
||||
t.Fatalf("create markdown = %q, want honest placeholder", md)
|
||||
}
|
||||
close(runner.release)
|
||||
waitUntil(t, func() bool {
|
||||
c.mgr.convMu.Lock()
|
||||
busy := c.mgr.convActive[c.run.ConversationID]
|
||||
c.mgr.convMu.Unlock()
|
||||
return !busy
|
||||
})
|
||||
}
|
||||
|
||||
// TestBackgroundCardSkippedWithoutInbox:收件箱未启用(cards==nil)时后台派发
|
||||
|
||||
@@ -116,6 +116,34 @@ func (eb *eventBuffer) emit(conversationID, runID string, typ core.StreamEventTy
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// emitProjectShared persists an explicit internal publication and its proof
|
||||
// before fan-out. A client-visible payload cannot opt itself into this path.
|
||||
func (eb *eventBuffer) emitProjectShared(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error {
|
||||
project, ok := core.ProjectFromChannel(channel)
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event := core.StreamEvent{ConversationID: channel, ProjectID: project, RunID: runID, Type: typ, Payload: encoded, TS: time.Now().UTC().Format(time.RFC3339Nano), V: core.ProtocolVersion}
|
||||
eb.flushConversation(channel)
|
||||
eb.flushing.Lock()
|
||||
defer eb.flushing.Unlock()
|
||||
events := []core.StreamEvent{event}
|
||||
if _, err := eb.store.AppendProjectSharedEvents(owner, producer, channel, events); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, event := range events {
|
||||
eb.bus.Publish(event)
|
||||
}
|
||||
if eb.onPersisted != nil {
|
||||
eb.onPersisted(channel, events)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// append 落盘并按 seq 顺序发布。
|
||||
func (eb *eventBuffer) append(conversationID string, evs []core.StreamEvent) error {
|
||||
eb.flushing.Lock()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/genui"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// SetGenUIAuto 注入自动建议闸门(组合根装配;nil 关闭)。
|
||||
func (m *Manager) SetGenUIAuto(a GenUIAutoTrigger) { m.genuiAuto = a }
|
||||
|
||||
func genUIAutoReminderText() string { return genui.AutoReminder }
|
||||
// genUIAutoReminderText 与 genui.AutoReminder 文案保持同步(runmgr 不得依赖 genui 包)。
|
||||
func genUIAutoReminderText() string {
|
||||
return "任务语义事件已发生(阶段或材料/工件完成)。若 generative-ui 已启用,请调用一次 generative-ui.present 生成或修订建议页,按当前任务阶段组织信息。不要点击页面 actions,不要改写 plan/todo/任务完成状态;任务事实只能来自真实任务记录。"
|
||||
}
|
||||
|
||||
// maybeGenUIAuto:语义事件钩子入口。会话忙碌时只追加 system 消息供下一轮消费;
|
||||
// 空闲时启动短 notice-run 让模型有机会立即 present。
|
||||
// maybeGenUIAuto:语义事件钩子入口。仅同步追加 system-reminder,供后续回合消费;
|
||||
// 不另起 notice-run(避免与在途 run / 测试拆卸竞态)。材料完成路径在
|
||||
// startNoticeRun 内联注入同一文案。
|
||||
func (m *Manager) maybeGenUIAuto(owner, convID, reason string) {
|
||||
if m == nil || m.genuiAuto == nil {
|
||||
return
|
||||
@@ -20,34 +23,12 @@ func (m *Manager) maybeGenUIAuto(owner, convID, reason string) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
go m.deliverGenUIAutoReminder(owner, convID, reason)
|
||||
}
|
||||
|
||||
func (m *Manager) deliverGenUIAutoReminder(owner, convID, reason string) {
|
||||
text := "<system-reminder>\n" + genUIAutoReminderText() + "\n(trigger=" + reason + ")\n</system-reminder>"
|
||||
msg := core.Message{
|
||||
ConversationID: convID, Role: core.RoleSystem,
|
||||
Parts: []core.Part{{Type: core.PartText, Text: text}},
|
||||
}
|
||||
if err := m.st.AppendMessage(&msg); err != nil {
|
||||
return
|
||||
}
|
||||
m.convMu.Lock()
|
||||
busy := m.convActive[convID]
|
||||
m.convMu.Unlock()
|
||||
if busy {
|
||||
return
|
||||
}
|
||||
conv, err := m.st.GetConversation(owner, convID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := m.claimConversation(convID); err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := m.startClaimedRun(conv, msg, nil, nil, nil); err != nil {
|
||||
m.releaseConversation(convID)
|
||||
}
|
||||
_ = m.st.AppendMessage(&msg)
|
||||
}
|
||||
|
||||
func planStageChanged(prev, next core.PlanSnapshot) bool {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/rag"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunKnowledgeResourceAuthorization(t *testing.T) {
|
||||
rc, st := newExecEnv(t)
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "allowed-slice", Owner: "bob", ProjectID: rc.run.ProjectID, Chunk: "resourcequery published"}, core.KnowledgeDoc{ID: "private-slice", Owner: "bob", ProjectID: rc.run.ProjectID, Chunk: "resourcequery private"}, core.KnowledgeDoc{ID: "restricted-self", Owner: "alice", ProjectID: rc.run.ProjectID, Chunk: "resourcequery self"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc.mgr.SetRetriever(rag.NewPipeline(st, rc.mgr.settings, nil, nil))
|
||||
rc.mgr.SetSharedKnowledgeAllow(func(string, core.ID) bool { return true })
|
||||
rc.mgr.SetKnowledgeResourceAllow(func(actor string, d core.KnowledgeDoc) bool {
|
||||
return actor == "alice" && d.ID == "allowed-slice" && d.Owner == "bob"
|
||||
})
|
||||
hits, err := rc.Retrieve(core.RetrievalQuery{Text: "resourcequery"})
|
||||
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "allowed-slice" {
|
||||
t.Fatalf("model-context resources: %+v %v", hits, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectWorkRevocationGuardsDirectExecution(t *testing.T) {
|
||||
rc, st := newExecEnv(t)
|
||||
rc.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return false, nil })
|
||||
conv, err := st.GetConversation("alice", rc.run.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rc.mgr.StartConversationRun(conv, core.Message{}, nil, nil); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("direct run should reject: %v", err)
|
||||
}
|
||||
res := rc.ExecuteTool(core.ToolCallRequest{Name: "write_artifact", Arguments: json.RawMessage(`{"name":"forbidden.md","content":"x"}`)})
|
||||
if res.Status != "error" {
|
||||
t.Fatalf("revoked execution: %+v", res)
|
||||
}
|
||||
artifacts, err := st.ListArtifacts("alice", rc.run.ProjectID)
|
||||
if err != nil || len(artifacts) != 0 {
|
||||
t.Fatalf("revoked tool wrote: %+v %v", artifacts, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunKnowledgeResolvesTrustedProjectScope(t *testing.T) {
|
||||
rc, st := newExecEnv(t)
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "bob", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "bob", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc.mgr.SetRetriever(rag.NewPipeline(st, rc.mgr.settings, nil, nil))
|
||||
rc.mgr.SetKnowledgeResourceAllow(func(string, core.KnowledgeDoc) bool { return true })
|
||||
rc.mgr.SetKnowledgeProjectScope(func(_ context.Context, actor string, id core.ID) (core.ID, error) {
|
||||
if actor != "alice" || id != rc.run.ProjectID {
|
||||
t.Fatalf("untrusted scope actor=%s project=%s", actor, id)
|
||||
}
|
||||
return "domain-current", nil
|
||||
})
|
||||
hits, err := rc.Retrieve(core.RetrievalQuery{Text: "scopeneedle"})
|
||||
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-current" {
|
||||
t.Fatalf("model current scope: %+v %v", hits, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectWorkRevocationBlocksQueuedHITL(t *testing.T) {
|
||||
c, st := newExecEnv(t)
|
||||
c.run.ID = "revoked-run"
|
||||
c.run.Status = core.RunWaitingHITL
|
||||
if err := st.CreateRun(&c.run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &runHandle{hitlCh: make(chan hitlAnswer, 1)}
|
||||
c.mgr.active = map[core.ID]*runHandle{c.run.ID: h}
|
||||
card := core.HITLCard{ID: "revoked-card", RunID: c.run.ID, ConversationID: c.run.ConversationID}
|
||||
if err := st.SaveHITLCard("alice", &card); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.mgr.SetProjectWorkAuthorization(func(_ context.Context, owner string, id core.ID) (bool, error) {
|
||||
if owner != "alice" || id != c.run.ProjectID {
|
||||
t.Fatalf("untrusted resolution identity owner=%s project=%s", owner, id)
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if _, _, err := c.mgr.ResolveHITL("alice", card.ID, core.HITLResponse{ChoiceID: "yes"}); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("revoked confirmation should reject: %v", err)
|
||||
}
|
||||
persisted, err := st.GetHITLCard("alice", card.ID)
|
||||
if err != nil || persisted.Resolved || len(h.hitlCh) != 0 {
|
||||
t.Fatalf("revoked confirmation changed gate: card=%+v channel=%d error=%v", persisted, len(h.hitlCh), err)
|
||||
}
|
||||
run, err := st.GetRunByID(c.run.ID)
|
||||
if err != nil || run.Status != core.RunWaitingHITL {
|
||||
t.Fatalf("revoked confirmation resumed run: %+v %v", run, err)
|
||||
}
|
||||
payload, _ := json.Marshal(HITLCommandPayload{Owner: "alice", CardID: card.ID, Kind: "yes"})
|
||||
c.mgr.applyHITLCommand(ownedCommand{CommandID: "queued-approval", Payload: string(payload)})
|
||||
if len(h.hitlCh) != 0 {
|
||||
t.Fatal("queued approval delivered a revoked answer")
|
||||
}
|
||||
run, err = st.GetRunByID(c.run.ID)
|
||||
if err != nil || run.Status != core.RunWaitingHITL {
|
||||
t.Fatalf("queued approval resumed run: %+v %v", run, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectWorkRevocationAfterToolConfirmation(t *testing.T) {
|
||||
for _, choice := range []string{"yes", "session"} {
|
||||
t.Run(choice, func(t *testing.T) {
|
||||
c, st := newExecEnv(t)
|
||||
c.h = &runHandle{hitlCh: make(chan hitlAnswer, 1)}
|
||||
c.h.hitlCh <- hitlAnswer{resp: core.HITLResponse{ChoiceID: choice}}
|
||||
c.prof.Tools.Confirm = []string{"write_artifact"}
|
||||
ready := true
|
||||
c.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { ok := ready; ready = false; return ok, nil })
|
||||
res := c.ExecuteTool(core.ToolCallRequest{CallID: "revoked-call", Name: "write_artifact", Arguments: json.RawMessage(`{"name":"revoked.txt","content":"x"}`)})
|
||||
artifacts, err := st.ListArtifacts("alice", c.run.ProjectID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != "error" || len(artifacts) != 0 {
|
||||
t.Fatalf("tool executed after revoked confirmation: status=%s artifacts=%d", res.Status, len(artifacts))
|
||||
}
|
||||
if c.mgr.sessionAllows(c.run.ConversationID, "write_artifact") {
|
||||
t.Fatal("revoked confirmation granted session permission")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectWorkRevocationBlocksDirectArtifactWrite(t *testing.T) {
|
||||
c, st := newExecEnv(t)
|
||||
c.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return false, nil })
|
||||
if _, err := c.WriteArtifact("revoked.txt", "data", "text/plain", []byte("private")); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("direct artifact should reject: %v", err)
|
||||
}
|
||||
artifacts, err := st.ListArtifacts("alice", c.run.ProjectID)
|
||||
if err != nil || len(artifacts) != 0 {
|
||||
t.Fatalf("revoked artifact was written: %+v %v", artifacts, err)
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,23 @@ func TestMaterialIntakeAttachmentOnlyLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
close(hold.release)
|
||||
waitUntil(t, func() bool { return len(systemTexts(t, c)) >= 1 })
|
||||
// 等完成去重门禁本身(CardDone + materialActive 已释放),不要等 notice
|
||||
// 副作用——deliverNotices 早于 defer materialKeyRelease,systemTexts 先可见会 race。
|
||||
key := materialKey(materialDocs())
|
||||
waitUntil(t, func() bool {
|
||||
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
|
||||
if err != nil || len(cards) != 1 {
|
||||
return false
|
||||
}
|
||||
card := cards[0]
|
||||
if card.Title != materialCardTitle || card.Status != core.CardDone || card.Tone == "danger" {
|
||||
return false
|
||||
}
|
||||
c.mgr.materialMu.Lock()
|
||||
active := c.mgr.materialActive != nil && c.mgr.materialActive[string(c.run.ConversationID)][key]
|
||||
c.mgr.materialMu.Unlock()
|
||||
return !active
|
||||
})
|
||||
|
||||
// 完成后同批再发:已完成说明,同样不重跑;卡片回投保留材料键。
|
||||
c3 := secondRunCtx(c, materialDocs(), "")
|
||||
@@ -259,7 +275,7 @@ func TestMaterialQueuedWhenFull(t *testing.T) {
|
||||
return false
|
||||
}
|
||||
for _, card := range cards {
|
||||
if card.Title == materialCardTitle && card.SourceRunID != "" && strings.Contains(cardBodyText(card), "执行中") {
|
||||
if card.Title == materialCardTitle && card.SourceRunID != "" && strings.Contains(cardBodyText(card), core.TaskSummaryStatusRunning) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
+135
-19
@@ -57,7 +57,10 @@ type Manager struct {
|
||||
retriever core.Retriever // 可选混合检索(nil = store 全文)
|
||||
// sharedKnowledgeAllow EXT-13:回合内检索的共享授权谓词(请求 owner × 知识所属
|
||||
// projectID);nil = 未注册(现状:仅本人两路,不触碰共享通道)。
|
||||
sharedKnowledgeAllow func(owner string, projectID core.ID) bool
|
||||
sharedKnowledgeAllow func(owner string, projectID core.ID) bool
|
||||
knowledgeResourceAllow func(owner string, doc core.KnowledgeDoc) bool
|
||||
knowledgeProjectScope func(context.Context, string, core.ID) (core.ID, error)
|
||||
projectWorkAuthorization func(context.Context, string, core.ID) (bool, error)
|
||||
// commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。
|
||||
commands CommandSource
|
||||
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
|
||||
@@ -176,6 +179,12 @@ type IntentGate interface {
|
||||
Match(ctx context.Context, input string) (branch, topID string)
|
||||
}
|
||||
|
||||
// EmitProjectSharedCardEvent is an internal producer-authorized structural
|
||||
// publication entry, separate from the ordinary private card event emitter.
|
||||
func (m *Manager) EmitProjectSharedCardEvent(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error {
|
||||
return m.events.emitProjectShared(owner, producer, channel, runID, typ, payload)
|
||||
}
|
||||
|
||||
// SetGate 注入意图闸门(装配期一次;插件意图 overlay 经 pluginhost 回调重建)。
|
||||
func (m *Manager) SetGate(g IntentGate) { m.gate = g }
|
||||
|
||||
@@ -197,6 +206,37 @@ func (m *Manager) SetSharedKnowledgeAllow(fn func(owner string, projectID core.I
|
||||
m.sharedKnowledgeAllow = fn
|
||||
}
|
||||
|
||||
// SetKnowledgeResourceAllow makes resource-level authorization authoritative for
|
||||
// model context, including owned rows. The callback receives stored identities.
|
||||
func (m *Manager) SetKnowledgeResourceAllow(fn func(string, core.KnowledgeDoc) bool) {
|
||||
m.knowledgeResourceAllow = fn
|
||||
}
|
||||
|
||||
// SetProjectWorkAuthorization is the live domain guard for starting runs and
|
||||
// executing tools. Nil preserves the ordinary unregistered host behavior.
|
||||
func (m *Manager) SetProjectWorkAuthorization(fn func(context.Context, string, core.ID) (bool, error)) {
|
||||
m.projectWorkAuthorization = fn
|
||||
}
|
||||
func (m *Manager) authorizeProjectWork(ctx context.Context, owner string, id core.ID) error {
|
||||
if m.projectWorkAuthorization == nil {
|
||||
return nil
|
||||
}
|
||||
ok, err := m.projectWorkAuthorization(ctx, owner, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetKnowledgeProjectScope binds host project filters to the provider's trusted
|
||||
// canonical domain metadata without widening the current conversation scope.
|
||||
func (m *Manager) SetKnowledgeProjectScope(fn func(context.Context, string, core.ID) (core.ID, error)) {
|
||||
m.knowledgeProjectScope = fn
|
||||
}
|
||||
|
||||
// SetCommandSource 注入命令即工具源(EXT-14;组合根以命令总线适配)。nil = 现状。
|
||||
func (m *Manager) SetCommandSource(src CommandSource) { m.commands = src }
|
||||
|
||||
@@ -596,7 +636,7 @@ func (c *runCtx) SetAssistantParts(parts []core.Part) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *runCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
|
||||
func (c *runCtx) AskHITL(card core.HITLCard) (answer core.HITLResponse, err error) {
|
||||
card.ID = store.NewID("hitl")
|
||||
card.RunID = c.run.ID
|
||||
card.ConversationID = c.run.ConversationID
|
||||
@@ -638,8 +678,13 @@ func (c *runCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
|
||||
// 排队,取消语境下重取失败由取消路径收口)。
|
||||
c.mgr.adm.release(c.mgr.st, string(c.run.ID))
|
||||
defer func() {
|
||||
if err := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner); err != nil {
|
||||
log.Printf("[runmgr] admission reacquire after HITL %s: %v", c.run.ID, err)
|
||||
if acquireErr := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner); acquireErr != nil {
|
||||
log.Printf("[runmgr] admission reacquire after HITL %s: %v", c.run.ID, acquireErr)
|
||||
}
|
||||
// Permission can change during both confirmation and admission waiting.
|
||||
// Every HITL caller receives the live decision before it resumes work.
|
||||
if err == nil {
|
||||
err = c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID)
|
||||
}
|
||||
}()
|
||||
return waitHITL(c.ctx, c.h.hitlCh, timeout)
|
||||
@@ -842,6 +887,9 @@ func (c *runCtx) Artifact(id string) (core.Artifact, []byte, bool) {
|
||||
}
|
||||
|
||||
func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
|
||||
if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return core.Artifact{}, err
|
||||
}
|
||||
art := core.Artifact{ProjectID: c.run.ProjectID, Owner: c.run.Owner, Name: name, Kind: kind, MIME: mime, SourceRun: c.run.ID}
|
||||
if err := c.mgr.st.CreateArtifact(&art, content); err != nil {
|
||||
return art, err
|
||||
@@ -902,6 +950,40 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
if q.ProjectID == "" {
|
||||
q.ProjectID = c.run.ProjectID
|
||||
}
|
||||
if c.mgr.knowledgeProjectScope != nil {
|
||||
id, err := c.mgr.knowledgeProjectScope(c.ctx, c.run.Owner, q.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q.ProjectID = id
|
||||
}
|
||||
if c.mgr.knowledgeResourceAllow != nil {
|
||||
allow := func(d core.KnowledgeDoc) bool { return c.mgr.knowledgeResourceAllow(string(c.run.Owner), d) }
|
||||
if rs, ok := c.mgr.retriever.(interface {
|
||||
RetrieveAuthorized(context.Context, core.ID, core.RetrievalQuery, func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error)
|
||||
}); ok {
|
||||
return rs.RetrieveAuthorized(c.ctx, c.run.Owner, q, allow)
|
||||
}
|
||||
hits, err := c.mgr.st.SearchKnowledge(c.run.Owner, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []core.RetrievalHit{}
|
||||
for _, h := range hits {
|
||||
if allow(h.Doc) {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
shared, err := c.mgr.st.SearchKnowledgeSharedAuthorized(c.run.Owner, q, allow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, shared...)
|
||||
if len(out) > q.TopK {
|
||||
out = out[:q.TopK]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if c.mgr.retriever != nil {
|
||||
if c.mgr.sharedKnowledgeAllow != nil {
|
||||
if rs, ok := c.mgr.retriever.(sharedRetriever); ok {
|
||||
@@ -917,6 +999,9 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
|
||||
// ExecuteTool 执行画像策略:deny→错误;confirm→HITL confirm 卡片;执行→截断转工件。
|
||||
func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
|
||||
}
|
||||
policy := c.prof.Tools
|
||||
if !policy.ToolAllowed(call.Name) {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q denied by profile policy", call.Name)}
|
||||
@@ -937,12 +1022,18 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
if err != nil {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "hitl aborted: " + err.Error()}
|
||||
}
|
||||
if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
|
||||
}
|
||||
if resp.ChoiceID == "session" {
|
||||
c.mgr.rememberSessionAllow(c.run.ConversationID, call.Name)
|
||||
} else if resp.ChoiceID != "yes" {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"}
|
||||
}
|
||||
}
|
||||
if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
|
||||
}
|
||||
// EXT-14:命令即工具——命中命令投影时经命令总分发(Entrance=agent、
|
||||
// Actor/Owner=会话 owner、带会话上下文),同审计信封与高危确认卡收口。
|
||||
if c.mgr.commands != nil {
|
||||
@@ -1141,6 +1232,9 @@ func selectConversationRunner(m *Manager, eff core.EffectiveSettings, launch *wo
|
||||
// attachments 为已解析的附件文档(server 层经 ingest 处理)。
|
||||
// inputs 是同一批附件的原件,落到会话工作区 inputs/,供模板和仿制使用。
|
||||
func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile) (core.Run, error) {
|
||||
if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil {
|
||||
return core.Run{}, err
|
||||
}
|
||||
if err := m.claimConversation(conv.ID); err != nil {
|
||||
return core.Run{}, err
|
||||
}
|
||||
@@ -1150,6 +1244,9 @@ func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.
|
||||
// StartWorkflowRun 启动一条绑定工作流的会话 run。定义在这里拷贝,
|
||||
// 之后插件卸载不会改到已经在跑的这份。
|
||||
func (m *Manager) StartWorkflowRun(conv core.Conversation, userMessage core.Message, def core.WorkflowDef, slots map[string]string, missing []workflow.SlotQuestion) (core.Run, error) {
|
||||
if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil {
|
||||
return core.Run{}, err
|
||||
}
|
||||
if err := m.claimConversation(conv.ID); err != nil {
|
||||
return core.Run{}, err
|
||||
}
|
||||
@@ -1259,6 +1356,10 @@ func (m *Manager) claimConversation(conversationID string) error {
|
||||
}
|
||||
|
||||
func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile, launch *workflow.Launch) (core.Run, error) {
|
||||
if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil {
|
||||
m.releaseConversation(conv.ID)
|
||||
return core.Run{}, err
|
||||
}
|
||||
prof, _ := m.profiles.Get(conv.Owner, conv.ProfileID)
|
||||
eff := m.settings.Effective(conv.Owner, conv.ProjectID)
|
||||
run := core.Run{
|
||||
@@ -1581,6 +1682,12 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if m.projectWorkAuthorization != nil && (run.Owner != owner || run.ConversationID != card.ConversationID) {
|
||||
return "", false, store.ErrNotFound
|
||||
}
|
||||
if err := m.authorizeProjectWork(context.Background(), run.Owner, run.ProjectID); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
// U7 双进程验收实测缺陷修复:run 不在本进程执行时不得先落 resolve——
|
||||
// 卡一旦标记 resolved,另一实例 server 层的命令 fallback(resolveHITL 的
|
||||
// !card.Resolved 判定)即被跳过,执行实例的 runner 永远等不到答案,
|
||||
@@ -1625,6 +1732,9 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
|
||||
}
|
||||
}
|
||||
if pivot {
|
||||
if err := m.authorizeProjectWork(context.Background(), run.Owner, run.ProjectID); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
_ = m.CancelRun(run.ID)
|
||||
m.st.ResolveHITLCard(owner, cardID, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
|
||||
m.emit(run.ConversationID, run.ID, core.EvHITLResolved, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
|
||||
@@ -1635,6 +1745,9 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
|
||||
return trimmed, true, nil
|
||||
}
|
||||
}
|
||||
if err := m.authorizeProjectWork(context.Background(), run.Owner, run.ProjectID); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
resolution := core.HITLResolvedPayload{CardID: core.ID(cardID), Answer: answer, Kind: kind}
|
||||
if err := m.st.ResolveHITLCard(owner, cardID, resolution); err != nil {
|
||||
return "", false, err
|
||||
@@ -1947,13 +2060,14 @@ func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, t
|
||||
if title == "" {
|
||||
title = "后台任务"
|
||||
}
|
||||
if status == "" {
|
||||
status = "执行中,完成后自动回投结果"
|
||||
}
|
||||
body := []core.UINode{
|
||||
{Type: core.UIMarkdown, Markdown: truncate(strings.TrimSpace(task), 400)},
|
||||
{Type: core.UIKV, KV: append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"}, [2]string{"状态", status})},
|
||||
_ = task // 派发说明保留入参兼容;用户可见正文用 L1 骨架,不回填原始指令。
|
||||
statusLabel := core.TaskSummaryStatusRunning
|
||||
if status != "" {
|
||||
statusLabel = status
|
||||
}
|
||||
focus := "正在执行:" + title
|
||||
extra := append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"})
|
||||
body := (core.TaskSummaryView{Focus: focus}).RunningBody(statusLabel, extra)
|
||||
card, err := m.cards.Create(parent.Owner, core.ActivityCard{
|
||||
Kind: core.CardKindProgress, Title: title, Status: core.CardActive, Tone: "info",
|
||||
ConversationID: parent.ConversationID, ProjectID: parent.ProjectID, SourceRunID: childID,
|
||||
@@ -1976,9 +2090,9 @@ func (m *Manager) promoteQueuedCard(owner string, card core.ActivityCard, childI
|
||||
if node.Type != core.UIKV {
|
||||
continue
|
||||
}
|
||||
for j, kv := range node.KV {
|
||||
if kv[0] == "状态" {
|
||||
card.Body[i].KV[j][1] = "执行中,完成后自动回投结果"
|
||||
for j, pair := range node.KV {
|
||||
if pair[0] == core.TaskSummaryKVStatus || pair[0] == "状态" {
|
||||
card.Body[i].KV[j][1] = core.TaskSummaryStatusRunning
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1988,15 +2102,19 @@ func (m *Manager) promoteQueuedCard(owner string, card core.ActivityCard, childI
|
||||
}
|
||||
|
||||
// finishBackgroundCard 把后台子代理终态写回进度卡:成功=结果摘要(含工件行),
|
||||
// 失败=错误说明;状态统一置 done,tone 区分成败。kv 原样保留(材料键等持久凭据)。
|
||||
// 失败=错误说明并置 CardFailed(U1:失败不得显示完成灯)。kv 原样保留。
|
||||
func (m *Manager) finishBackgroundCard(owner string, card core.ActivityCard, payload core.AgentResultPayload, err error, kv [][2]string) {
|
||||
if m.cards == nil || card.ID == "" {
|
||||
return
|
||||
}
|
||||
summary := strings.TrimSpace(payload.Summary)
|
||||
tone := "ok"
|
||||
status := core.CardDone
|
||||
statusLabel := core.TaskSummaryStatusDone
|
||||
if err != nil {
|
||||
tone = "danger"
|
||||
status = core.CardFailed
|
||||
statusLabel = core.TaskSummaryStatusFailed
|
||||
if summary == "" {
|
||||
summary = err.Error()
|
||||
}
|
||||
@@ -2005,14 +2123,12 @@ func (m *Manager) finishBackgroundCard(owner string, card core.ActivityCard, pay
|
||||
if summary == "" {
|
||||
summary = "(无结果摘要)"
|
||||
}
|
||||
card.Body = []core.UINode{
|
||||
{Type: core.UIMarkdown, Markdown: summary},
|
||||
{Type: core.UIKV, KV: append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"}, [2]string{"状态", "已完成"})},
|
||||
}
|
||||
extra := append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"})
|
||||
card.Body = (core.TaskSummaryView{OutcomeLine: summary}).FinishedBody(statusLabel, extra)
|
||||
if uerr := m.cards.Update(owner, card); uerr != nil {
|
||||
log.Printf("[runmgr] background card update: %v", uerr)
|
||||
}
|
||||
if _, serr := m.cards.SetStatus(owner, card.ID, core.CardDone, tone, ""); serr != nil {
|
||||
if _, serr := m.cards.SetStatus(owner, card.ID, status, tone, ""); serr != nil {
|
||||
log.Printf("[runmgr] background card status: %v", serr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ type fakeRunCtx struct {
|
||||
memEntries []memEntry // 非空时 StoreMemory 返回之(N3 WP5 记忆注入断言用)
|
||||
usageTotal core.Usage // AddUsage 累计(N3 WP6 记账断言用)
|
||||
bgCalls int
|
||||
runtimeExtra map[string]any // 覆盖 runtime 组设置(双速提醒阈值等测试用)
|
||||
runtimeExtra map[string]any // 覆盖 runtime 组设置(双速提醒阈值等测试用)
|
||||
attachments []core.ParsedDocument // Attachments 返回值(双速入口前置提示测试用)
|
||||
spawnAgentFn func(agentID, name, task, hint string) (core.AgentResultPayload, error)
|
||||
execStatus string // 非空时覆盖 ExecuteTool 状态(R4 失败链测试)
|
||||
|
||||
+177
-29
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -60,20 +61,73 @@ func loginClear(owner string) {
|
||||
delete(loginGuard.fails, owner)
|
||||
}
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, token string, maxAge int) {
|
||||
// 兑换入口退避:共享库窗口计数(v29 redeem_attempts),多实例共担同一限流;
|
||||
// Key 无属主可先行归因,只能按来源 IP 限流。
|
||||
const (
|
||||
redeemMaxFails = 10
|
||||
redeemWindow = loginLockWindow
|
||||
)
|
||||
|
||||
// requestSecure 判断请求是否经 HTTPS(直连 TLS 或反代 X-Forwarded-Proto 标头)。
|
||||
func requestSecure(r *http.Request) bool {
|
||||
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, maxAge int) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: SessionCookie, Value: token, Path: "/", HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode, MaxAge: maxAge,
|
||||
SameSite: http.SameSiteLaxMode, MaxAge: maxAge, Secure: requestSecure(r),
|
||||
})
|
||||
}
|
||||
|
||||
func authStateJSON(st store.UserAuthState) map[string]any {
|
||||
return map[string]any{
|
||||
"owner": st.Owner, "name": st.Name, "isAdmin": st.IsAdmin,
|
||||
"mustChangePassword": st.MustChangePassword, "auth": true,
|
||||
"mustChangePassword": st.MustChangePassword, "passwordSet": st.HasPassword,
|
||||
"auth": true,
|
||||
}
|
||||
}
|
||||
|
||||
// redeemLoginKey:一次性登录 Key → set_password_only 会话(专用 body,不经 Bearer)。
|
||||
// 请求只有 Key:身份由服务端绑定记录确定;按来源 IP 退避防爆破。
|
||||
func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Auth == nil || !s.Auth.Enabled {
|
||||
http.Error(w, "auth disabled (embedded single-tenant mode)", http.StatusPreconditionFailed)
|
||||
return
|
||||
}
|
||||
ip, _, _ := strings.Cut(r.RemoteAddr, ":")
|
||||
if xf := r.Header.Get("X-Forwarded-For"); xf != "" {
|
||||
ip = strings.TrimSpace(strings.Split(xf, ",")[0])
|
||||
}
|
||||
if blocked, err := s.st.RedeemBlocked(ip, redeemMaxFails, redeemWindow); err == nil && blocked {
|
||||
http.Error(w, "too many failed attempts; retry later", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
token, owner, err := s.st.RedeemLoginKey(strings.TrimSpace(req.Key))
|
||||
if err != nil {
|
||||
_ = s.st.RecordRedeemFailure(ip) // 尽力记录;限流判定的权威在 RedeemBlocked
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
http.Error(w, "login key already used", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
http.Error(w, "invalid or expired login key", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, r, token, int(10*60))
|
||||
st, _ := s.st.GetUserAuthState(owner)
|
||||
out := authStateJSON(st)
|
||||
out["sessionScope"] = store.SessionScopeSetPasswordOnly
|
||||
out["reloginRequired"] = false
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// login:用户名+密码 → 会话。失败统一 401(不区分账号是否存在);
|
||||
// 连续失败触发进程内退避(429)。
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -98,20 +152,34 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "too many failed attempts; retry later", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
st, ok := s.st.VerifyLogin(owner, req.Password)
|
||||
st, ver, ok := s.st.VerifyLoginWithVersion(owner, req.Password)
|
||||
if !ok {
|
||||
loginFail(owner)
|
||||
http.Error(w, "invalid owner or password", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
loginClear(owner)
|
||||
token, err := s.st.CreateSession(owner)
|
||||
// 待改密/未设密:建受限会话(只能设密);否则正常会话。会话创建绑定
|
||||
// 认证版本——密码验证在途期间账户被恢复/重置时本请求不得再建会话。
|
||||
scope := store.SessionScopeFull
|
||||
maxAge := 7 * 24 * 3600
|
||||
if st.MustChangePassword || !st.HasPassword {
|
||||
scope = store.SessionScopeSetPasswordOnly
|
||||
maxAge = 10 * 60
|
||||
}
|
||||
token, err := s.st.CreateSessionVerified(owner, scope, time.Duration(maxAge)*time.Second, ver)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
http.Error(w, "credentials changed concurrently; retry login", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, token, 7*24*3600)
|
||||
writeJSON(w, http.StatusOK, authStateJSON(st))
|
||||
setSessionCookie(w, r, token, maxAge)
|
||||
out := authStateJSON(st)
|
||||
out["sessionScope"] = scope
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// logout:注销当前会话。
|
||||
@@ -119,19 +187,21 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
if tok := sessionToken(r); tok != "" {
|
||||
s.st.DeleteSession(tok)
|
||||
}
|
||||
setSessionCookie(w, "", -1)
|
||||
setSessionCookie(w, r, "", -1)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// changePassword:会话登录者改密。校验当前密码 → 新哈希落库 → 清 must_change →
|
||||
// 吊销其余会话(保留当前)。重置后的首登改密走同一路径。
|
||||
// changePassword:会话登录者改密。
|
||||
// - 受限会话或 must_change/未设密:不要求 current;事务内复验会话与认证版本,
|
||||
// 完成后销毁全部会话,要求重新密码登录(reloginRequired=true)。
|
||||
// - 普通主动改密:要求 current;单事务完成校验、更新、吊销其他会话(保留当前)。
|
||||
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
tok := sessionToken(r)
|
||||
if tok == "" {
|
||||
http.Error(w, "session login required (API keys do not own passwords)", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
owner, ok := s.st.SessionOwner(tok)
|
||||
owner, scope, ok := s.st.SessionInfo(tok)
|
||||
if !ok {
|
||||
http.Error(w, "session expired", http.StatusUnauthorized)
|
||||
return
|
||||
@@ -144,10 +214,6 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, ok := s.st.VerifyLogin(owner, req.Current); !ok {
|
||||
http.Error(w, "current password incorrect", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err := validPassword(req.Next); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -157,18 +223,36 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.st.SetUserPassword(owner, hash); err != nil {
|
||||
st0, _ := s.st.GetUserAuthState(owner)
|
||||
forceSet := scope == store.SessionScopeSetPasswordOnly || st0.MustChangePassword || !st0.HasPassword
|
||||
if forceSet {
|
||||
if _, err := s.st.CompleteSetPasswordChecked(tok, hash); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.Error(w, "session expired", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, r, "", -1)
|
||||
st, _ := s.st.GetUserAuthState(owner)
|
||||
out := authStateJSON(st)
|
||||
out["reloginRequired"] = true
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
if err := s.st.ChangeUserPasswordActive(owner, req.Current, hash, tok); err != nil {
|
||||
if errors.Is(err, store.ErrBadCredentials) {
|
||||
http.Error(w, "current password incorrect", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
// SetUserPassword 置了 must_change=1(重置语义);本人主动改密后即清除。
|
||||
if err := s.st.ClearMustChangePassword(owner); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.st.DeleteUserSessions(owner, tok)
|
||||
st, _ := s.st.GetUserAuthState(owner)
|
||||
writeJSON(w, http.StatusOK, authStateJSON(st))
|
||||
out := authStateJSON(st)
|
||||
out["reloginRequired"] = false
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func validPassword(pw string) error {
|
||||
@@ -202,7 +286,8 @@ func (s *Server) listAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAdmin(w, r); !ok {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
@@ -229,7 +314,7 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.st.CreateAccount(req.Owner, strings.TrimSpace(req.Name), hash, req.IsAdmin); err != nil {
|
||||
if _, err := s.st.CreateActivatedAccount(store.AccountWrite{Owner: req.Owner, Name: strings.TrimSpace(req.Name), PasswordHash: hash, IsAdmin: req.IsAdmin, Actor: actor, Action: "account.create"}); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -240,9 +325,12 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// resetAdminUserPassword:管理员重置密码 → must_change=1,吊销该用户全部会话。
|
||||
// resetAdminUserPassword:管理员直接重置密码(兼容保留的既有入口;常规恢复
|
||||
// 走签发一次性登录 Key)。单事务:must_change=1、bump auth_version、作废未用
|
||||
// 登录 Key、吊销全部会话、审计。
|
||||
func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAdmin(w, r); !ok {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
owner := r.PathValue("owner")
|
||||
@@ -262,17 +350,77 @@ func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.st.SetUserPassword(owner, hash); err != nil {
|
||||
if err := s.st.AdminResetUserPassword(owner, hash, actor, "admin-http"); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
s.st.DeleteUserSessions(owner, "")
|
||||
st, _ := s.st.GetUserAuthState(owner)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"owner": st.Owner, "mustChangePassword": st.MustChangePassword,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 管理员:一次性登录 Key(恢复流程主入口)----
|
||||
|
||||
// issueAdminLoginKey:为指定既有用户签发恢复 Key(明文仅本次响应返回)。
|
||||
// 同事务撤销旧恢复 Key 及其受限会话;不改密码、不吊销正常会话、不动业务数据。
|
||||
func (s *Server) issueAdminLoginKey(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
owner := strings.TrimSpace(r.PathValue("owner"))
|
||||
var req struct {
|
||||
Purpose string `json:"purpose"`
|
||||
TTLMinutes int `json:"ttlMinutes"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil && !errors.Is(err, io.EOF) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
purpose := req.Purpose
|
||||
if purpose == "" {
|
||||
purpose = store.LoginKeyPurposeRecover
|
||||
}
|
||||
if purpose != store.LoginKeyPurposeRecover && purpose != store.LoginKeyPurposeActivate {
|
||||
http.Error(w, "purpose must be recover or activate", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ttl := time.Duration(req.TTLMinutes) * time.Minute
|
||||
plain, meta, err := s.st.IssueLoginKey(owner, purpose, actor, ttl)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": meta.ID, "owner": meta.Owner, "purpose": meta.Purpose,
|
||||
"status": meta.Status, "expiresAt": meta.ExpiresAt, "createdBy": meta.CreatedBy,
|
||||
"key": plain, // 明文仅此一次;关闭弹层后不可再取
|
||||
})
|
||||
}
|
||||
|
||||
// listAdminLoginKeys:指定用户的 Key 状态与有效期(不返回明文)。
|
||||
func (s *Server) listAdminLoginKeys(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
keys, err := s.st.ListLoginKeys(r.PathValue("owner"))
|
||||
writeJSONOr(w, map[string]any{"keys": keys}, err)
|
||||
}
|
||||
|
||||
// revokeAdminLoginKey:撤销 Key 并作废其关联受限会话。
|
||||
func (s *Server) revokeAdminLoginKey(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := s.st.RevokeLoginKeyCascade(r.PathValue("id"), actor); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// RandomPassword 生成随机初始密码(管理员/CLI 未显式给定密码时)。
|
||||
func RandomPassword() string {
|
||||
raw := make([]byte, 9)
|
||||
|
||||
+82
-18
@@ -14,13 +14,16 @@ type ownerCtxKey struct{}
|
||||
// authViaCtxKey 携带凭证通道(AuthViaKey / AuthViaSession;未启用认证与豁免路径为空)。
|
||||
type authViaCtxKey struct{}
|
||||
|
||||
// sessionScopeCtxKey 携带会话 scope(full / set_password_only)。
|
||||
type sessionScopeCtxKey struct{}
|
||||
|
||||
// 凭证通道值:/api/me 的 authVia 字段;前端据此区分「改密门」只对会话登录者放行。
|
||||
const (
|
||||
AuthViaKey = "key"
|
||||
AuthViaSession = "session"
|
||||
)
|
||||
|
||||
// Authenticator 是 API key 认证(阶段 1:user 即租户)。
|
||||
// Authenticator 是 API key 认证(阶段 1:owner 即租户)。
|
||||
// Enabled=false(嵌入式档默认,回环本地单用户)时全部请求回落默认租户。
|
||||
// SSE 的 EventSource 无法带 Authorization 头,允许 ?key= 查询参数兜底。
|
||||
type Authenticator struct {
|
||||
@@ -41,41 +44,94 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r.WithContext(withOwner(r, store.OwnerDefault)))
|
||||
return
|
||||
}
|
||||
// 豁免:静态前端(登录页须可加载)与登录入口本身。
|
||||
p := r.URL.Path
|
||||
if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" {
|
||||
// 豁免:静态前端、登录、一次性 Key 兑换(body 专用,不走 Bearer)。
|
||||
if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" || p == "/api/auth/login-key/redeem" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// 凭证二选一:Authorization: Bearer <api key>(程序化;?key= 兼容 SSE 老客户端)
|
||||
// 或会话 cookie(浏览器登录;2026-09-30 R2 管理员发放制)。
|
||||
// 失效 key 不一票否决:浏览器 localStorage 可能残留已吊销 key(前端对所有请求
|
||||
// 附加 Bearer 头),会话 cookie 仍有效时回落会话;程序化客户端不带 cookie,
|
||||
// 行为不变(两者都无效才 401)。
|
||||
key := bearerKey(r)
|
||||
if key == "" {
|
||||
key = r.URL.Query().Get("key")
|
||||
}
|
||||
// 一次性登录 Key 不得经 Bearer / ?key= 使用。
|
||||
if strings.HasPrefix(key, "alk_") {
|
||||
http.Error(w, "unauthorized: one-time login keys must use /api/auth/login-key/redeem", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var owner string
|
||||
var via string
|
||||
var scope string
|
||||
ok := false
|
||||
if key != "" {
|
||||
if owner, ok := a.st.AuthOwner(key); ok {
|
||||
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaKey)))
|
||||
if o, keyOK := a.st.AuthOwner(key); keyOK {
|
||||
owner, via, scope, ok = o, AuthViaKey, store.SessionScopeFull, true
|
||||
} else if o, sc, sessOK := cookieSession(r, a.st); sessOK {
|
||||
owner, via, scope, ok = o, AuthViaSession, sc, true
|
||||
} else {
|
||||
http.Error(w, "unauthorized: invalid or revoked API key", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if owner, ok := cookieOwner(r, a.st); ok {
|
||||
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaSession)))
|
||||
return
|
||||
}
|
||||
http.Error(w, "unauthorized: invalid or revoked API key", http.StatusUnauthorized)
|
||||
} else if o, sc, sessOK := cookieSession(r, a.st); sessOK {
|
||||
owner, via, scope, ok = o, AuthViaSession, sc, true
|
||||
}
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized: login required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if owner, ok := cookieOwner(r, a.st); ok {
|
||||
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaSession)))
|
||||
ctx := withAuthVia(withOwner(r, owner), via)
|
||||
ctx = context.WithValue(ctx, sessionScopeCtxKey{}, scope)
|
||||
if err := a.enforcePasswordGate(w, r, owner, via, scope, p); err != nil {
|
||||
return
|
||||
}
|
||||
http.Error(w, "unauthorized: login required", http.StatusUnauthorized)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Authenticator) enforcePasswordGate(w http.ResponseWriter, r *http.Request, owner, via, scope, path string) error {
|
||||
allow := path == "/api/me" || path == "/api/auth/password" || path == "/api/auth/logout" ||
|
||||
path == "/api/health"
|
||||
if scope == store.SessionScopeSetPasswordOnly {
|
||||
if !allow {
|
||||
http.Error(w, "forbidden: set-password session only", http.StatusForbidden)
|
||||
return errGate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
st, err := a.st.GetUserAuthState(owner)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// 长期 API Key:仅当 must_change(已有密码待改)时拒绝业务,防止绕过改密门;
|
||||
// 无密码的纯集成账户仍可走 Key(与历史 apikey 语义兼容)。
|
||||
if via == AuthViaKey {
|
||||
if st.MustChangePassword && !allow {
|
||||
http.Error(w, "forbidden: password change required", http.StatusForbidden)
|
||||
return errGate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 会话:待改密或尚未设密 → 只允许身份/设密/退出。
|
||||
if (st.MustChangePassword || !st.HasPassword) && !allow {
|
||||
http.Error(w, "forbidden: password setup required", http.StatusForbidden)
|
||||
return errGate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var errGate = errorsGate{}
|
||||
|
||||
type errorsGate struct{}
|
||||
|
||||
func (errorsGate) Error() string { return "gate" }
|
||||
|
||||
func cookieSession(r *http.Request, st *store.Store) (owner, scope string, ok bool) {
|
||||
if tok := sessionToken(r); tok != "" {
|
||||
return st.SessionInfo(tok)
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// cookieOwner 会话 cookie 有效时返回其 owner。
|
||||
func cookieOwner(r *http.Request, st *store.Store) (string, bool) {
|
||||
if tok := sessionToken(r); tok != "" {
|
||||
@@ -108,6 +164,14 @@ func AuthVia(r *http.Request) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// SessionScope 返回当前会话 scope(非会话为空)。
|
||||
func SessionScope(r *http.Request) string {
|
||||
if v, ok := r.Context().Value(sessionScopeCtxKey{}).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func bearerKey(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(h, "Bearer ") {
|
||||
|
||||
@@ -321,10 +321,29 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
return rec
|
||||
}
|
||||
|
||||
// 会话可访问 API;/api/me 携带身份与管理员位。
|
||||
// /api/me 允许 must_change;业务 API 拒绝(U4 设密门)。
|
||||
if rec := session("GET", "/api/me", ""); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"isAdmin":true`) {
|
||||
t.Fatalf("me via session: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := session("POST", "/api/admin/users", `{"owner":"alice","name":"爱丽丝","password":"alice-init-01"}`); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("must_change admin must be gated before create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// 首登改密:不要求 current;成功后销毁会话,须重新密码登录。
|
||||
if rec := session("POST", "/api/auth/password", `{"next":"admin-final-9"}`); rec.Code != http.StatusOK ||
|
||||
!strings.Contains(rec.Body.String(), `"reloginRequired":true`) {
|
||||
t.Fatalf("admin force set-password: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := session("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("admin session must die after set-password: %d", rec.Code)
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"admin-final-9"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
|
||||
t.Fatalf("admin relogin after set-password: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
tok = strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0]
|
||||
|
||||
// 管理员建号(普通用户,初始密码)。
|
||||
if rec := session("POST", "/api/admin/users", `{"owner":"alice","name":"爱丽丝","password":"alice-init-01"}`); rec.Code != http.StatusOK {
|
||||
@@ -335,7 +354,7 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
t.Fatalf("duplicate owner must 409: %d", rec.Code)
|
||||
}
|
||||
|
||||
// alice 首登:mustChangePassword=true;改密后清除。
|
||||
// alice 首登:mustChangePassword=true;业务 API 被设密门拒绝。
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-init-01"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
@@ -358,16 +377,19 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
if !strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
|
||||
t.Fatalf("alice first login must force change: %s", rec.Body.String())
|
||||
}
|
||||
// alice 非管理员:建号被拒 403。
|
||||
if rec := alice("POST", "/api/admin/users", `{"owner":"bob","password":"bob-init-001"}`); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-admin create must 403: %d", rec.Code)
|
||||
t.Fatalf("must_change non-admin create must 403: %d", rec.Code)
|
||||
}
|
||||
// alice 改密(错误当前密码 → 401;成功 → must_change 清除)。
|
||||
if rec := alice("POST", "/api/auth/password", `{"current":"bad","next":"alice-final-9"}`); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong current password: %d", rec.Code)
|
||||
// must_change 改密忽略 current;短密码拒绝;成功后 reloginRequired。
|
||||
if rec := alice("POST", "/api/auth/password", `{"next":"short"}`); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("short password must 400: %d", rec.Code)
|
||||
}
|
||||
if rec := alice("POST", "/api/auth/password", `{"current":"alice-init-01","next":"alice-final-9"}`); rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
|
||||
t.Fatalf("alice change password: %d %s", rec.Code, rec.Body.String())
|
||||
if rec := alice("POST", "/api/auth/password", `{"current":"ignored","next":"alice-final-9"}`); rec.Code != http.StatusOK ||
|
||||
!strings.Contains(rec.Body.String(), `"reloginRequired":true`) {
|
||||
t.Fatalf("alice force set-password: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("alice session must die after set-password: %d", rec.Code)
|
||||
}
|
||||
// 旧密码不能再登录;新密码可以且无需改密。
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-init-01"}`))
|
||||
@@ -377,6 +399,18 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("old password must fail after change: %d", rec.Code)
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-final-9"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
|
||||
t.Fatalf("alice relogin: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
aliceTok = strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0]
|
||||
// 已设密普通用户:建号仍 403(非管理员)。
|
||||
if rec := alice("POST", "/api/admin/users", `{"owner":"bob","password":"bob-init-001"}`); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-admin create must 403: %d", rec.Code)
|
||||
}
|
||||
|
||||
// 管理员重置 alice 密码 → 其旧会话吊销 + 首登再改密。
|
||||
if rec := session("POST", "/api/admin/users/alice/password", `{"password":"reset-init-02"}`); rec.Code != http.StatusOK {
|
||||
@@ -493,3 +527,79 @@ func TestStaleKeySessionFallback(t *testing.T) {
|
||||
t.Fatalf("me via session: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemSettingsAdminGate:P2 系统设置面按管理员准入——普通账号 403,
|
||||
// 管理员可读写且值对所有租户生效;普通账号写受管项仍 400。
|
||||
func TestSystemSettingsAdminGate(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
|
||||
hash, err := store.HashPassword("init-pass-01")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("boss", "管理员甲", hash, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("dave", "普通乙", hash, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyBoss, err := st.CreateAPIKey("boss", "cli")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyDave, err := st.CreateAPIKey("dave", "cli")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 初始密码改密门不参与本测试对象(受管拦截语义),先解除。
|
||||
if err := st.ClearMustChangePassword("boss"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.ClearMustChangePassword("dave"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
do := func(key, method, path string, body string) *httptest.ResponseRecorder {
|
||||
var rd io.Reader
|
||||
if body != "" {
|
||||
rd = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, rd)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// 普通账号:读写系统面均 403;写受管项走 user 面仍是 400(先于管理员判定也成立)。
|
||||
if rec := do(keyDave, "GET", "/api/settings/system", ""); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("dave get system: %d", rec.Code)
|
||||
}
|
||||
if rec := do(keyDave, "PUT", "/api/settings/system", `{"models":{"chat":{"model":"m-dave"}}}`); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("dave put system: %d", rec.Code)
|
||||
}
|
||||
if rec := do(keyDave, "PUT", "/api/settings/user", `{"models":{"chat":{"model":"m-dave"}}}`); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("dave managed via user: %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := do(keyDave, "PUT", "/api/settings/user", `{"ui":{"theme":"light"}}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("dave non-managed via user: %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 管理员:写入生效,系统读面掩码返回,dave 的有效设置拿到系统值。
|
||||
if rec := do(keyBoss, "PUT", "/api/settings/system", `{"models":{"chat":{"model":"m-boss","apiKey":"sk-boss-secret"}}}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("boss put system: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec := do(keyBoss, "GET", "/api/settings/system", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("boss get system: %d", rec.Code)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "sk-boss-secret") {
|
||||
t.Fatalf("system read leaked secret key")
|
||||
}
|
||||
if eff := settings.NewService(st).Effective("dave", ""); eff.GetString("models.chat.model", "") != "m-boss" {
|
||||
t.Fatalf("system value not effective for dave: %q", eff.GetString("models.chat.model", ""))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,10 @@ func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.projectWork(r.Context(), owner, conv.ProjectID); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
MessageID string `json:"messageId"`
|
||||
}
|
||||
|
||||
+95
-45
@@ -48,12 +48,14 @@ func newTestServer(t *testing.T) (*Server, *store.Store) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
_ = st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": true}})
|
||||
|
||||
b := bus.New()
|
||||
reg := tools.NewRegistry()
|
||||
reg.RegisterBuiltin()
|
||||
setSvc := settings.NewService(st)
|
||||
// P2 受管:runtime.mock 是系统受管项,mock 驱动改经系统层写入(对所有租户生效)。
|
||||
if err := setSvc.MergeSystem(map[string]any{"runtime": map[string]any{"mock": true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profSvc := profiles.New(st)
|
||||
profSvc.EnsureDefault()
|
||||
mgr := runmgr.New(st, b, reg, profSvc, setSvc, &runtime.LLMRunner{}, &runtime.SubLLMRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{})
|
||||
@@ -1010,41 +1012,59 @@ func TestSquadOrchestration(t *testing.T) {
|
||||
t.Fatal("squad deliverable artifact not found")
|
||||
}
|
||||
|
||||
// TestModelRoutingOverrideChain:设置覆盖链集成(defaults → user → project)
|
||||
// 与画像层最终覆盖——经由真实 run 的失败信息携带解析出的模型名取证。
|
||||
// TestModelRoutingOverrideChain:P2 受管语义下的模型配置链路——models.* 是系统
|
||||
// 受管项,user/project 层整笔拒绝(含嵌套与点路径),仅系统层可写;画像层模型名
|
||||
// 仍是最终覆盖。经由真实 run 的失败信息携带解析出的模型名取证。
|
||||
func TestModelRoutingOverrideChain(t *testing.T) {
|
||||
srv, st := newTestServer(t)
|
||||
h := srv.Handler()
|
||||
projectID, convID := setupConversation(t, h)
|
||||
|
||||
set := func(body map[string]any, path string) {
|
||||
code, out := doJSON(t, h, "PUT", path, body)
|
||||
if code != 200 {
|
||||
t.Fatalf("PUT %s: %d %v", path, code, out)
|
||||
// user/project 层写受管项 → 400(含点路径与整体子树覆盖)。
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
path string
|
||||
body map[string]any
|
||||
}{
|
||||
{"nested user", "/api/settings/user", map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-A"}}}},
|
||||
{"dotted user", "/api/settings/user", map[string]any{"models.chat.model": "model-A"}},
|
||||
{"nested project", "/api/settings/project/" + projectID, map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-B"}}}},
|
||||
{"subtree smash user", "/api/settings/user", map[string]any{"models": "smash"}},
|
||||
} {
|
||||
code, out := doJSON(t, h, "PUT", tc.path, tc.body)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("%s: PUT %s = %d %v,want 400", tc.name, tc.path, code, out)
|
||||
}
|
||||
}
|
||||
|
||||
// user 层:model-A。
|
||||
set(map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-A"}}}, "/api/settings/user")
|
||||
eff := settings.NewService(st).Effective(store.OwnerDefault, projectID)
|
||||
if got := eff.GetString("models.chat.model", ""); got != "model-A" {
|
||||
t.Fatalf("user layer: %q", got)
|
||||
}
|
||||
// project 层:model-B(覆盖 user)。
|
||||
set(map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-B"}}}, "/api/settings/project/"+projectID)
|
||||
eff = settings.NewService(st).Effective(store.OwnerDefault, projectID)
|
||||
if got := eff.GetString("models.chat.model", ""); got != "model-B" {
|
||||
t.Fatalf("project layer should win: %q", got)
|
||||
}
|
||||
// 设置 API 呈现两层。
|
||||
code, out := doJSON(t, h, "GET", "/api/settings?project="+projectID, nil)
|
||||
// 系统层(无认证测试档 = 本机管理员)写入生效。
|
||||
code, out := doJSON(t, h, "PUT", "/api/settings/system", map[string]any{
|
||||
"models": map[string]any{"chat": map[string]any{"model": "model-SYS"}},
|
||||
})
|
||||
if code != 200 {
|
||||
t.Fatal("get settings failed")
|
||||
t.Fatalf("system write: %d %v", code, out)
|
||||
}
|
||||
if eff := settings.NewService(st).Effective(store.OwnerDefault, projectID); eff.GetString("models.chat.model", "") != "model-SYS" {
|
||||
t.Fatalf("system layer not effective: %q", eff.GetString("models.chat.model", ""))
|
||||
}
|
||||
// 系统 GET 面:schema 携带 scope,系统值脱敏返回。
|
||||
code, out = doJSON(t, h, "GET", "/api/settings/system", nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("get system: %d %v", code, out)
|
||||
}
|
||||
sysView := out
|
||||
if sysView["system"] == nil || sysView["schema"] == nil {
|
||||
t.Fatalf("system view malformed: %#v", sysView)
|
||||
}
|
||||
// 非受管项仍可走 user 层(回归)。
|
||||
if code, _ := doJSON(t, h, "PUT", "/api/settings/user", map[string]any{"ui": map[string]any{"theme": "light"}}); code != 200 {
|
||||
t.Fatalf("non-managed user write rejected")
|
||||
}
|
||||
_ = out
|
||||
|
||||
// 画像层:model-C 为最终覆盖。关 mock → 发消息 → run 失败信息携带解析结果。
|
||||
set(map[string]any{"runtime": map[string]any{"mock": false}}, "/api/settings/user")
|
||||
// 画像层:model-C 为最终覆盖。关 mock(系统层)→ 发消息 → run 失败信息携带解析结果。
|
||||
if code, _ := doJSON(t, h, "PUT", "/api/settings/system", map[string]any{"runtime": map[string]any{"mock": false}}); code != 200 {
|
||||
t.Fatal("disable mock via system layer failed")
|
||||
}
|
||||
prof := mustCode(t, h, "POST", "/api/profiles", map[string]any{
|
||||
"id": "prof_route_c", "name": "路由C", "systemPrompt": "x",
|
||||
"model": map[string]any{"chat": "model-C"},
|
||||
@@ -1205,18 +1225,27 @@ func TestProjectInbox(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsDottedKeyAndSecretMask:点路径写入生效、嵌套胜出、掩码不回写、GET 不回明文。
|
||||
// TestSettingsDottedKeyAndSecretMask:点路径写入生效、受管密钥归系统层、
|
||||
// 掩码不回写、user/system 读面均不回明文、user 面不回显受管项(P2)。
|
||||
func TestSettingsDottedKeyAndSecretMask(t *testing.T) {
|
||||
srv, st := newTestServer(t)
|
||||
h := srv.Handler()
|
||||
svc := settings.NewService(st)
|
||||
|
||||
set := func(body map[string]any) {
|
||||
t.Helper()
|
||||
code, out := doJSON(t, h, "PUT", "/api/settings/user", body)
|
||||
if code != 200 {
|
||||
t.Fatalf("PUT: %d %v", code, out)
|
||||
t.Fatalf("PUT user: %d %v", code, out)
|
||||
}
|
||||
}
|
||||
setSys := func(body map[string]any) {
|
||||
t.Helper()
|
||||
code, out := doJSON(t, h, "PUT", "/api/settings/system", body)
|
||||
if code != 200 {
|
||||
t.Fatalf("PUT system: %d %v", code, out)
|
||||
}
|
||||
}
|
||||
svc := settings.NewService(st)
|
||||
|
||||
set(map[string]any{"runtime.entry": "squad"})
|
||||
if got := svc.Effective(store.OwnerDefault, "").GetString("runtime.entry", ""); got != "squad" {
|
||||
@@ -1232,21 +1261,25 @@ func TestSettingsDottedKeyAndSecretMask(t *testing.T) {
|
||||
|
||||
const pool = "tvly-should-not-leak"
|
||||
const modelKey = "sk-should-not-leak"
|
||||
set(map[string]any{"tools": map[string]any{"tavily": map[string]any{"apiKeys": pool}}})
|
||||
set(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": modelKey}}})
|
||||
set(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": settings.MaskPlaceholder}}})
|
||||
set(map[string]any{
|
||||
"tools": map[string]any{"tavily": map[string]any{"apiKeys": "nested-wins"}},
|
||||
"tools.tavily.apiKeys": "flat-loses",
|
||||
})
|
||||
// P2 受管:密钥项 user 层整笔拒绝,系统层写入生效。
|
||||
if code, _ := doJSON(t, h, "PUT", "/api/settings/user", map[string]any{
|
||||
"tools": map[string]any{"tavily": map[string]any{"apiKeys": pool}},
|
||||
}); code != http.StatusBadRequest {
|
||||
t.Fatalf("managed pool write via user should 400, got %d", code)
|
||||
}
|
||||
setSys(map[string]any{"tools": map[string]any{"tavily": map[string]any{"apiKeys": pool}}})
|
||||
setSys(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": modelKey}}})
|
||||
// 掩码回写经系统层:不覆盖已存明文。
|
||||
setSys(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": settings.MaskPlaceholder}}})
|
||||
eff := svc.Effective(store.OwnerDefault, "")
|
||||
if got := eff.GetString("models.chat.apiKey", ""); got != modelKey {
|
||||
t.Fatalf("mask writeback overwrote key: %q", got)
|
||||
}
|
||||
if got := eff.GetString("tools.tavily.apiKeys", ""); got != "nested-wins" {
|
||||
t.Fatalf("nested form should win, got %q", got)
|
||||
if got := eff.GetString("tools.tavily.apiKeys", ""); got != pool {
|
||||
t.Fatalf("system pool not effective: %q", got)
|
||||
}
|
||||
|
||||
// 读面:user 面不回显受管项;系统面返回掩码;两个面都不泄漏明文。
|
||||
code, out := doJSON(t, h, "GET", "/api/settings", nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("GET settings: %d", code)
|
||||
@@ -1255,19 +1288,36 @@ func TestSettingsDottedKeyAndSecretMask(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, secret := range []string{pool, modelKey, "nested-wins", "flat-loses"} {
|
||||
for _, secret := range []string{pool, modelKey} {
|
||||
if strings.Contains(string(blob), secret) {
|
||||
t.Fatalf("GET leaked %q", secret)
|
||||
t.Fatalf("GET /api/settings leaked %q", secret)
|
||||
}
|
||||
}
|
||||
user := out["user"].(map[string]any)
|
||||
tavily := user["tools"].(map[string]any)["tavily"].(map[string]any)
|
||||
if tavily["apiKeys"] != settings.MaskPlaceholder {
|
||||
t.Fatalf("apiKeys mask = %v", tavily["apiKeys"])
|
||||
if user["models"] != nil || user["tools"] != nil {
|
||||
t.Fatalf("managed subtrees exposed in user scope: %#v", user)
|
||||
}
|
||||
chat := user["models"].(map[string]any)["chat"].(map[string]any)
|
||||
code, sys := doJSON(t, h, "GET", "/api/settings/system", nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("GET system: %d", code)
|
||||
}
|
||||
sysBlob, err := json.Marshal(sys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, secret := range []string{pool, modelKey} {
|
||||
if strings.Contains(string(sysBlob), secret) {
|
||||
t.Fatalf("GET /api/settings/system leaked %q", secret)
|
||||
}
|
||||
}
|
||||
sysValues := sys["system"].(map[string]any)
|
||||
tavily := sysValues["tools"].(map[string]any)["tavily"].(map[string]any)
|
||||
if tavily["apiKeys"] != settings.MaskPlaceholder {
|
||||
t.Fatalf("system apiKeys mask = %v", tavily["apiKeys"])
|
||||
}
|
||||
chat := sysValues["models"].(map[string]any)["chat"].(map[string]any)
|
||||
if chat["apiKey"] != settings.MaskPlaceholder {
|
||||
t.Fatalf("apiKey mask = %v", chat["apiKey"])
|
||||
t.Fatalf("system apiKey mask = %v", chat["apiKey"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func TestRedeemLoginKeyRestrictedSession(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
if err := st.CreateAccount("carol", "", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, _, err := st.IssueLoginKey("carol", store.LoginKeyPurposeActivate, "test", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"key": plain})
|
||||
req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("redeem: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
cookie := rec.Result().Cookies()
|
||||
var sess string
|
||||
for _, c := range cookie {
|
||||
if c.Name == SessionCookie {
|
||||
sess = c.Value
|
||||
}
|
||||
}
|
||||
if sess == "" {
|
||||
t.Fatal("missing session cookie")
|
||||
}
|
||||
// 业务拒绝
|
||||
req = httptest.NewRequest("GET", "/api/projects", nil)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: sess})
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("projects want 403, got %d", rec.Code)
|
||||
}
|
||||
// me 允许
|
||||
req = httptest.NewRequest("GET", "/api/me", nil)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: sess})
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("me: %d", rec.Code)
|
||||
}
|
||||
// 设密后要求重新登录
|
||||
pwBody, _ := json.Marshal(map[string]string{"next": "carol-final-99"})
|
||||
req = httptest.NewRequest("POST", "/api/auth/password", bytes.NewReader(pwBody))
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: sess})
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || !bytes.Contains(rec.Body.Bytes(), []byte(`"reloginRequired":true`)) {
|
||||
t.Fatalf("set password: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package server
|
||||
|
||||
// 登录恢复流程 HTTP 合同(2026-10-13):管理员签发一次性登录 Key → 用户兑换 →
|
||||
// 受限会话强制设密 → 重新密码登录;must-change 首登同样建受限会话。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// bootAdmin:建管理员并完成首登改密,返回已放行的会话 cookie 值。
|
||||
func bootAdmin(t *testing.T, h http.Handler, st *store.Store) string {
|
||||
t.Helper()
|
||||
bootHash, err := store.HashPassword("boot-pass-00")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("admin", "", bootHash, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"boot-pass-00"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("boot login: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
tok := cookieOf(rec)
|
||||
if tok == "" {
|
||||
t.Fatal("missing session cookie")
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/password", strings.NewReader(`{"next":"admin-final-9"}`))
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok})
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("boot set password: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"admin-final-9"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("relogin: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return cookieOf(rec)
|
||||
}
|
||||
|
||||
func cookieOf(rec *httptest.ResponseRecorder) string {
|
||||
for _, ck := range rec.Result().Cookies() {
|
||||
if ck.Name == SessionCookie {
|
||||
return ck.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func reqWithCookie(method, path, body, cookie string) *http.Request {
|
||||
var rdr *bytes.Reader
|
||||
if body != "" {
|
||||
rdr = bytes.NewReader([]byte(body))
|
||||
} else {
|
||||
rdr = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, rdr)
|
||||
if cookie != "" {
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: cookie})
|
||||
}
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func do(h http.Handler, method, path, body, cookie string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, reqWithCookie(method, path, body, cookie))
|
||||
return rec
|
||||
}
|
||||
|
||||
// 管理员签发 → 列表无明文 → 兑换 → 受限 → 设密 → 新密码登录。
|
||||
func TestAdminLoginKeyRecoveryFlow(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
adminTok := bootAdmin(t, h, st)
|
||||
if err := st.CreateAccount("mallory", "", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("pleb", "", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 匿名/无会话不能签发。
|
||||
if rec := do(h, "POST", "/api/admin/users/mallory/login-keys", `{}`, "not-a-session"); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous issue: %d", rec.Code)
|
||||
}
|
||||
|
||||
// 管理员签发:返回明文一次 + 元数据。
|
||||
rec := do(h, "POST", "/api/admin/users/mallory/login-keys", `{}`, adminTok)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("issue: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var issued struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &issued); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(issued.Key, "alk_") || issued.ID == "" {
|
||||
t.Fatalf("issue payload: %+v", issued)
|
||||
}
|
||||
|
||||
// 列表:状态可见、无明文。
|
||||
rec = do(h, "GET", "/api/admin/users/mallory/login-keys", "", adminTok)
|
||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "alk_") {
|
||||
t.Fatalf("list must not leak plaintext: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"status":"unused"`) {
|
||||
t.Fatalf("list should show unused status: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// 用户兑换:只提交 Key,身份由服务端绑定决定。
|
||||
req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+issued.Key+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"owner":"mallory"`) ||
|
||||
!strings.Contains(rec.Body.String(), `"sessionScope":"set_password_only"`) {
|
||||
t.Fatalf("redeem: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
userTok := cookieOf(rec)
|
||||
|
||||
// 重复兑换失败。
|
||||
req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+issued.Key+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("replay redeem: %d", rec.Code)
|
||||
}
|
||||
|
||||
// 受限会话:业务与管理员入口均拒绝。
|
||||
if rec := do(h, "GET", "/api/projects", "", userTok); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("business api: %d", rec.Code)
|
||||
}
|
||||
if rec := do(h, "POST", "/api/admin/users/pleb/login-keys", `{}`, userTok); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("restricted session must not issue keys: %d", rec.Code)
|
||||
}
|
||||
|
||||
// 设密(无 current)→ reloginRequired → 新密码登录。
|
||||
rec = do(h, "POST", "/api/auth/password", `{"next":"mallory-final-9"}`, userTok)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"reloginRequired":true`) {
|
||||
t.Fatalf("set password: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"mallory","password":"mallory-final-9"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
|
||||
t.Fatalf("login with new password: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 补发与撤销:旧 Key 作废、旧受限会话失效。
|
||||
func TestAdminLoginKeyReissueAndRevoke(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
adminTok := bootAdmin(t, h, st)
|
||||
if err := st.CreateAccount("nia", "", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issue := func() (string, string) {
|
||||
rec := do(h, "POST", "/api/admin/users/nia/login-keys", `{}`, adminTok)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("issue: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
return out.ID, out.Key
|
||||
}
|
||||
oldID, oldKey := issue()
|
||||
_ = oldID
|
||||
|
||||
// 兑换旧 Key 得受限会话;补发后该会话失效、旧 Key 不可再用。
|
||||
req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+oldKey+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
oldSess := cookieOf(rec)
|
||||
if oldSess == "" {
|
||||
t.Fatal("redeem old key failed")
|
||||
}
|
||||
issue()
|
||||
if rec := do(h, "GET", "/api/me", "", oldSess); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("restricted session must die after reissue: %d", rec.Code)
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+oldKey+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatal("consumed old key must not redeem again")
|
||||
}
|
||||
|
||||
// 撤销端点:未用 Key 撤销后不可兑换。
|
||||
newID, newKey := issue()
|
||||
if rec := do(h, "DELETE", "/api/admin/users/nia/login-keys/"+newID, "", adminTok); rec.Code != http.StatusOK {
|
||||
t.Fatalf("revoke: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+newKey+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatal("revoked key must not redeem")
|
||||
}
|
||||
// 不存在的编号 404。
|
||||
if rec := do(h, "DELETE", "/api/admin/users/nia/login-keys/lk_nope", "", adminTok); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown key revoke: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// must-change 首登:登录即建受限会话(sessionScope 回传),设密后重新登录为 full。
|
||||
func TestLoginMustChangeCreatesRestrictedSession(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
bootHash, err := store.HashPassword("temp-pass-00")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("oscar", "", bootHash, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"oscar","password":"temp-pass-00"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"sessionScope":"set_password_only"`) {
|
||||
t.Fatalf("must-change login must create restricted session: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
tok := cookieOf(rec)
|
||||
if rec := do(h, "GET", "/api/projects", "", tok); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("business api before set: %d", rec.Code)
|
||||
}
|
||||
rec = do(h, "POST", "/api/auth/password", `{"next":"oscar-final-9"}`, tok)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("set password: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"oscar","password":"oscar-final-9"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"sessionScope":"full"`) {
|
||||
t.Fatalf("full login after set: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 主动改密:必须带正确 current;成功保留当前会话。
|
||||
func TestActiveChangePasswordRequiresCurrent(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
_ = srv
|
||||
if err := st.CreateAccount("petra", "", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, _, err := st.IssueLoginKey("petra", store.LoginKeyPurposeActivate, "test", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+plain+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
tok := cookieOf(rec)
|
||||
rec = do(h, "POST", "/api/auth/password", `{"next":"petra-first-8"}`, tok)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("initial set: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// 重新登录得 full 会话。
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"petra","password":"petra-first-8"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
tok = cookieOf(rec)
|
||||
// 缺 current / 错 current 拒绝。
|
||||
if rec := do(h, "POST", "/api/auth/password", `{"next":"petra-second-9"}`, tok); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("active change without current: %d", rec.Code)
|
||||
}
|
||||
if rec := do(h, "POST", "/api/auth/password", `{"current":"wrong-current","next":"petra-second-9"}`, tok); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("active change with wrong current: %d", rec.Code)
|
||||
}
|
||||
// 正确 current 成功且当前会话保留。
|
||||
rec = do(h, "POST", "/api/auth/password", `{"current":"petra-first-8","next":"petra-second-9"}`, tok)
|
||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"reloginRequired":true`) {
|
||||
t.Fatalf("active change: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := do(h, "GET", "/api/me", "", tok); rec.Code != http.StatusOK {
|
||||
t.Fatalf("current session must survive active change: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -273,6 +273,20 @@ func (s *Server) submitSurfaceAction(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
if s.projectLifecycle != nil {
|
||||
snap, err := s.Surfaces.Get(r.Context(), s.owner(r), core.ID(r.PathValue("id")))
|
||||
if err != nil {
|
||||
s.writeSurfaceError(w, err)
|
||||
return
|
||||
}
|
||||
if snap.Identity.ProjectID != "" {
|
||||
if err := s.projectWork(r.Context(), s.owner(r), snap.Identity.ProjectID); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 注意:本端点不绑定任何 confirmed 类字段——高危确认只能经既有确认卡回道
|
||||
// (bus.Confirm)置位,客户端/模型无法伪造(U5 提案第二节第 2 条红测)。
|
||||
var body struct {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// projectStreamMembership consults the registered resource authorizer using the
|
||||
// physical project's trusted owner, never the event publisher or payload claims.
|
||||
func (s *Server) projectStreamMembership(actor, project string) bool {
|
||||
p, err := s.st.ProjectMetadata(project)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return s.authorizeSharedResource(actor, "stream", "project", project, p.Owner, project)
|
||||
}
|
||||
|
||||
func (s *Server) projectEventVisible(actor string, event core.StreamEvent) bool {
|
||||
if s.projectLifecycle == nil {
|
||||
return true
|
||||
}
|
||||
access, err := s.st.ResolveProjectEventAccess(event)
|
||||
if err != nil || access.Owner == "" {
|
||||
return false
|
||||
}
|
||||
if access.Owner == actor {
|
||||
return true
|
||||
}
|
||||
project, ok := core.ProjectFromChannel(event.ConversationID)
|
||||
return ok && access.Shared && s.projectStreamMembership(actor, project)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/cardsvc"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func projectStreamReplay(t *testing.T, s *Server, key, project string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest("GET", "/api/events?scope=project&projectId="+project+"&since=0", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+key)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, r.WithContext(ctx))
|
||||
return w
|
||||
}
|
||||
func projectEventFixture(t *testing.T) (*Server, string, string, string, *atomic.Bool) {
|
||||
t.Helper()
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent)
|
||||
s.Cards.SetProjectSharedEmitter(s.mgr.EmitProjectSharedCardEvent)
|
||||
member := &atomic.Bool{}
|
||||
member.Store(true)
|
||||
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
|
||||
return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || (q.Actor == "bob" && member.Load())), nil
|
||||
})
|
||||
return s, alice, bob, p.ID, member
|
||||
}
|
||||
func TestProjectEventsLegacyReplayIsPrivate(t *testing.T) {
|
||||
s, alice, bob, p, _ := projectEventFixture(t)
|
||||
s.DisableUploadIndexing = true
|
||||
if w := lifecycleUpload(t, s, alice, p, "legacy-owner-private.txt", "PRIVATE"); w.Code != 200 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return true, nil }}, nil)
|
||||
member := projectStreamReplay(t, s, bob, p)
|
||||
if member.Code != 200 || bytes.Contains(member.Body.Bytes(), []byte("legacy-owner-private.txt")) || bytes.Contains(member.Body.Bytes(), []byte("builtin:txt")) {
|
||||
t.Fatal("legacy private upload leaked to member")
|
||||
}
|
||||
owner := projectStreamReplay(t, s, alice, p)
|
||||
if owner.Code != 200 || !bytes.Contains(owner.Body.Bytes(), []byte("legacy-owner-private.txt")) {
|
||||
t.Fatal("owner lost private history")
|
||||
}
|
||||
}
|
||||
func TestProjectEventsSharedProofAndPrivatePagination(t *testing.T) {
|
||||
s, _, bob, p, _ := projectEventFixture(t)
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil)
|
||||
card, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p, Title: "PRIVATE HISTORY"}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"card": card, "owner": "bob", "shared": true, "producer": "plugin:rdm:structure"})
|
||||
private := make([]core.StreamEvent, 1001)
|
||||
for i := range private {
|
||||
private[i] = core.StreamEvent{Type: core.EvCardUpdated, Payload: payload, TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1}
|
||||
}
|
||||
if _, err := s.st.AppendEvents(core.ProjectChannelKey(p), private); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p, Title: "EXPLICIT SHARED"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := projectStreamReplay(t, s, bob, p)
|
||||
if w.Code != 200 || bytes.Contains(w.Body.Bytes(), []byte("PRIVATE HISTORY")) || !bytes.Contains(w.Body.Bytes(), []byte("EXPLICIT SHARED")) {
|
||||
t.Fatal("private pagination/spoof proof failed")
|
||||
}
|
||||
}
|
||||
func TestProjectEventsLivePrivacyAndRevocation(t *testing.T) {
|
||||
s, _, bob, p, member := projectEventFixture(t)
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil)
|
||||
httpServer := httptest.NewServer(s.Handler())
|
||||
defer httpServer.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", httpServer.URL+"/api/events?scope=project&projectId="+p, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+bob)
|
||||
response, err := httpServer.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if _, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p, Title: "LIVE PRIVATE"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p, Title: "LIVE SHARED"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader := bufio.NewReader(response.Body)
|
||||
var before bytes.Buffer
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("live shared event did not arrive: %v", err)
|
||||
}
|
||||
before.WriteString(line)
|
||||
if bytes.Contains([]byte(line), []byte("LIVE SHARED")) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if bytes.Contains(before.Bytes(), []byte("LIVE PRIVATE")) {
|
||||
t.Fatal("live private frame leaked before revocation")
|
||||
}
|
||||
member.Store(false)
|
||||
if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p, Title: "AFTER REMOVAL"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("revoked stream did not stop: %v", err)
|
||||
}
|
||||
if bytes.Contains(body, []byte("LIVE PRIVATE")) || bytes.Contains(body, []byte("AFTER REMOVAL")) {
|
||||
t.Fatal("live private/revoked event leaked")
|
||||
}
|
||||
}
|
||||
func TestUnregisteredProjectEventsRetainLegacyReads(t *testing.T) {
|
||||
s, _, bob, p, _ := projectEventFixture(t)
|
||||
if _, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p, Title: "LEGACY BASE"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := projectStreamReplay(t, s, bob, p)
|
||||
if w.Code != 200 || !bytes.Contains(w.Body.Bytes(), []byte("LEGACY BASE")) {
|
||||
t.Fatal("unregistered project stream changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectEventsIdleStreamStopsAfterMembershipRemoval(t *testing.T) {
|
||||
previous := sseAuthRecheck
|
||||
sseAuthRecheck = 20 * time.Millisecond
|
||||
defer func() { sseAuthRecheck = previous }()
|
||||
s, _, bob, p, member := projectEventFixture(t)
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil)
|
||||
httpServer := httptest.NewServer(s.Handler())
|
||||
defer httpServer.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
request, _ := http.NewRequestWithContext(ctx, "GET", httpServer.URL+"/api/events?scope=project&projectId="+p, nil)
|
||||
request.Header.Set("Authorization", "Bearer "+bob)
|
||||
response, err := httpServer.Client().Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
member.Store(false)
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil || !bytes.Contains(body, []byte("project-access-revoked")) {
|
||||
t.Fatal("idle project stream kept revoked member connected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// SetProjectLifecycle is wired only for a registered in-process provider. The
|
||||
// optional host factory constructs a capability scoped to this request's actor.
|
||||
func (s *Server) SetProjectLifecycle(p extension.ProjectLifecycle, host func(string) extension.Host) {
|
||||
s.projectLifecycle = &p
|
||||
s.projectHost = host
|
||||
}
|
||||
func (s *Server) lifecycleHost(owner string) extension.Host {
|
||||
if s.projectHost == nil {
|
||||
return nil
|
||||
}
|
||||
return s.projectHost(owner)
|
||||
}
|
||||
func (s *Server) projectWork(ctx context.Context, owner, projectID string) error {
|
||||
if s.projectLifecycle == nil {
|
||||
return nil
|
||||
}
|
||||
if s.projectLifecycle.Work == nil {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
ok, err := s.projectLifecycle.Work(ctx, owner, core.ID(projectID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createPrivateConversation is shared by the API and /new. Only live work
|
||||
// authorization grants member creation; the resulting conversation stays owned.
|
||||
func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID, title, profile string) (core.Conversation, error) {
|
||||
if s.projectLifecycle != nil {
|
||||
if err := s.projectWork(ctx, owner, projectID); err != nil {
|
||||
return core.Conversation{}, err
|
||||
}
|
||||
return s.st.CreateConversationInProject(owner, projectID, title, profile)
|
||||
}
|
||||
c, err := s.st.CreateConversation(owner, projectID, title, profile)
|
||||
if errors.Is(err, store.ErrNotFound) && s.authorizeSharedResource(owner, "read", "project", core.ID(projectID), "", core.ID(projectID)) {
|
||||
return s.st.CreateConversationInProject(owner, projectID, title, profile)
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
func writeProjectErr(w http.ResponseWriter, err error) {
|
||||
var coded interface{ StatusCode() int }
|
||||
if errors.As(err, &coded) {
|
||||
switch code := coded.StatusCode(); code {
|
||||
case 400, 403, 404, 409, 503:
|
||||
http.Error(w, err.Error(), code)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeStoreErr(w, err)
|
||||
}
|
||||
func writeProjectJSON(w http.ResponseWriter, p any, err error) {
|
||||
if err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
// publicProjectMetadata also masks provider-returned metadata, since providers
|
||||
// may obtain raw owner-management rows before producing a shared public view.
|
||||
func (s *Server) publicProjectMetadata(p core.Project) core.Project {
|
||||
if s.projectLifecycle != nil {
|
||||
p.Settings = s.set.MaskedProjectMetadata(p.Settings)
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/cardsvc"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/surfacesvc"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type lifecycleStatusError int
|
||||
|
||||
func (e lifecycleStatusError) Error() string { return "provider refusal" }
|
||||
func (e lifecycleStatusError) StatusCode() int { return int(e) }
|
||||
func lifecycleRequest(t *testing.T, s *Server, key, method, path, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
r.Header.Set("Authorization", "Bearer "+key)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
func TestProjectLifecycleDelegatesAndPreservesArchiveHistory(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Original")
|
||||
c, _ := st.CreateConversation("alice", p.ID, "Private", "")
|
||||
calls := []string{}
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{
|
||||
List: func(context.Context, extension.Host) ([]core.Project, error) {
|
||||
calls = append(calls, "list")
|
||||
return []core.Project{{ID: p.ID, Owner: "alice", Name: "Decorated", Settings: map[string]any{"rdm.state": "ready"}}}, nil
|
||||
},
|
||||
Create: func(_ context.Context, _ extension.Host, name string) (core.Project, error) {
|
||||
calls = append(calls, "create")
|
||||
return core.Project{ID: "provider-created", Name: name}, nil
|
||||
},
|
||||
Rename: func(_ context.Context, _ extension.Host, id, name string) (core.Project, error) {
|
||||
calls = append(calls, "rename")
|
||||
return core.Project{ID: id, Name: name}, nil
|
||||
},
|
||||
Archive: func(context.Context, extension.Host, string) error { calls = append(calls, "archive"); return nil },
|
||||
Work: func(context.Context, string, core.ID) (bool, error) { return true, nil },
|
||||
}, nil)
|
||||
for _, q := range []struct{ method, path, body string }{{"GET", "/api/projects", ""}, {"POST", "/api/projects", `{"name":" New "}`}, {"PATCH", "/api/projects/" + p.ID, `{"name":"Renamed"}`}, {"DELETE", "/api/projects/" + p.ID, ""}} {
|
||||
w := lifecycleRequest(t, s, key, q.method, q.path, q.body)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("%s: %d %s", q.method, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
if len(calls) != 4 || calls[0] != "list" || calls[1] != "create" || calls[2] != "rename" || calls[3] != "archive" {
|
||||
t.Fatalf("calls=%v", calls)
|
||||
}
|
||||
if _, err := st.GetConversation("alice", c.ID); err != nil {
|
||||
t.Fatalf("archive removed history: %v", err)
|
||||
}
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Create: func(context.Context, extension.Host, string) (core.Project, error) {
|
||||
return core.Project{}, fmtWrap(lifecycleStatusError(409))
|
||||
}}, nil)
|
||||
if w := lifecycleRequest(t, s, key, "POST", "/api/projects", `{"name":"dup"}`); w.Code != 409 {
|
||||
t.Fatalf("trusted status=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
func fmtWrap(err error) error { return errors.Join(errors.New("wrapped"), err) }
|
||||
func TestProjectLifecycleMemberPrivateWork(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
mallory, _ := st.CreateAPIKey("mallory", "test")
|
||||
p, _ := st.CreateProject("alice", "Shared")
|
||||
ac, _ := st.CreateConversation("alice", p.ID, "Alice private", "")
|
||||
ready := true
|
||||
s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
|
||||
return ready && (actor == "alice" || actor == "bob") && id == core.ID(p.ID), nil
|
||||
}}, nil)
|
||||
w := lifecycleRequest(t, s, bob, "POST", "/api/projects/"+p.ID+"/conversations", `{"title":"Bob private"}`)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("member create=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var bc core.Conversation
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &bc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w = lifecycleRequest(t, s, bob, "GET", "/api/projects/"+p.ID+"/conversations", "")
|
||||
var cs []core.Conversation
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &cs)
|
||||
if w.Code != 200 || len(cs) != 1 || cs[0].ID != bc.ID {
|
||||
t.Fatalf("member list=%d %+v", w.Code, cs)
|
||||
}
|
||||
if w := lifecycleRequest(t, s, bob, "GET", "/api/conversations/"+ac.ID, ""); w.Code != 404 {
|
||||
t.Fatalf("private history=%d", w.Code)
|
||||
}
|
||||
if w := lifecycleRequest(t, s, mallory, "POST", "/api/projects/"+p.ID+"/conversations", `{}`); w.Code != 404 {
|
||||
t.Fatalf("nonmember create=%d", w.Code)
|
||||
}
|
||||
if w := lifecycleRequest(t, s, mallory, "GET", "/api/projects/"+p.ID+"/conversations", ""); w.Code != 404 {
|
||||
t.Fatalf("nonmember list=%d", w.Code)
|
||||
}
|
||||
s.Bus = cmdbus.New(st, nil, func() time.Time { return time.Now() })
|
||||
s.WireBuiltinCommands()
|
||||
w = lifecycleRequest(t, s, bob, "POST", "/api/conversations/"+bc.ID+"/messages", `{"text":"/new"}`)
|
||||
if w.Code != 200 || !bytes.Contains(w.Body.Bytes(), []byte(`"conversation"`)) {
|
||||
t.Fatalf("/new=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
ready = false
|
||||
for _, q := range []struct{ key, method, path, body string }{{bob, "POST", "/api/projects/" + p.ID + "/conversations", `{}`}, {bob, "GET", "/api/projects/" + p.ID + "/conversations", ""}, {alice, "POST", "/api/conversations/" + ac.ID + "/messages", `{"text":"new work"}`}, {bob, "POST", "/api/commands/conversation.create", `{"conversationId":"` + bc.ID + `"}`}} {
|
||||
w := lifecycleRequest(t, s, q.key, q.method, q.path, q.body)
|
||||
if w.Code != 404 {
|
||||
t.Fatalf("revoked %s=%d %s", q.path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
if w := lifecycleRequest(t, s, bob, "GET", "/api/conversations/"+bc.ID, ""); w.Code != 200 {
|
||||
t.Fatalf("owned revoked history=%d", w.Code)
|
||||
}
|
||||
}
|
||||
func TestProjectLifecycleReadOnlyAuthorizerDoesNotGrantWrite(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
p, _ := st.CreateProject("alice", "Shared")
|
||||
s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil)
|
||||
if w := lifecycleRequest(t, s, key, http.MethodPost, "/api/projects/"+p.ID+"/conversations", `{}`); w.Code != 404 {
|
||||
t.Fatalf("read-only write=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if cs, err := st.ListConversations("bob", p.ID); err != nil || len(cs) != 0 {
|
||||
t.Fatalf("denial wrote state: %v %v", cs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectLifecycleRevocationGuardsExecutionResumption(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
conv, _ := st.CreateConversation("alice", p.ID, "Private", "")
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return false, nil }}, nil)
|
||||
card := core.HITLCard{ID: "hitl-revoked", ConversationID: conv.ID, RunID: "run-revoked", Kind: "confirm"}
|
||||
if err := st.SaveHITLCard("alice", &card); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, q := range []struct{ path, body string }{{"/api/conversations/" + conv.ID + "/regenerate", `{}`}, {"/api/hitl/" + card.ID + "/resolve", `{"choiceId":"yes"}`}} {
|
||||
w := lifecycleRequest(t, s, key, "POST", q.path, q.body)
|
||||
if w.Code != 404 {
|
||||
t.Fatalf("resumption %s=%d %s", q.path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectLifecycleRevocationGuardsSurfaceAction(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
conv, _ := st.CreateConversation("alice", p.ID, "Private", "")
|
||||
svc := surfacesvc.New(st, nil)
|
||||
s.Surfaces = svc
|
||||
if _, err := svc.SetActivation(context.Background(), "alice", "generative-ui", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap, err := svc.Submit(context.Background(), "alice", "generative-ui", surfacesvc.SubmitRequest{ConversationID: conv.ID, Spec: json.RawMessage(`{"title":"Page","nodes":[{"type":"actions","id":"a1","actions":[{"id":"generative-ui.submit","label":"Submit"}]}]}`)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return false, nil }}, nil)
|
||||
w := lifecycleRequest(t, s, key, "POST", "/api/surfaces/"+snap.Identity.SurfaceID+"/actions", `{"actionId":"generative-ui.submit","revision":1,"requestId":"request"}`)
|
||||
if w.Code != 404 {
|
||||
t.Fatalf("revoked surface action=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectLifecycleSettingsRetainServiceMetadata(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
|
||||
current, err := st.GetProject("alice", p.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if current.Settings == nil {
|
||||
current.Settings = map[string]any{}
|
||||
}
|
||||
current.Settings["rdm.projectId"] = "canonical"
|
||||
current.Settings["rdm.state"] = "ready"
|
||||
return []core.Project{current}, nil
|
||||
}}, nil)
|
||||
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, `{"settings":{"rdm.projectId":"forged","example":true}}`)
|
||||
var got core.Project
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||||
if w.Code != 200 || got.Settings["rdm.projectId"] != "canonical" || got.Settings["example"] != true {
|
||||
t.Fatalf("settings lost service metadata: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectLifecyclePublicMetadataMasksSecrets(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
p, _ := st.CreateProject("alice", "Shared")
|
||||
raw := map[string]any{"models.chat.apiKey": "fixture-key", "custom": map[string]any{"token": "fixture-token"}, "custom.api_key": "fixture-flat-token", "plugin.private.credential": "fixture-schema-token", "display": map[string]any{"caption": "Shared"}, "rdm.projectId": "canonical", "rdm.state": "ready"}
|
||||
if err := st.UpdateProjectSettings("alice", p.ID, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.set.SetExtraSchema([]core.SettingDesc{{Key: "plugin.private.credential", Secret: true}})
|
||||
read := func() (core.Project, error) { return st.GetProject("alice", p.ID) }
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
|
||||
current, err := read()
|
||||
return []core.Project{current}, err
|
||||
}, Create: func(context.Context, extension.Host, string) (core.Project, error) { return read() }, Rename: func(context.Context, extension.Host, string, string) (core.Project, error) { return read() }}, nil)
|
||||
for _, q := range []struct{ key, method, path, body string }{{bob, "GET", "/api/projects", ""}, {alice, "GET", "/api/projects", ""}, {alice, "POST", "/api/projects", `{"name":"New"}`}, {alice, "PATCH", "/api/projects/" + p.ID, `{"name":"Renamed"}`}, {alice, "PATCH", "/api/projects/" + p.ID, `{"settings":{"display":{"caption":"Updated"}}}`}} {
|
||||
w := lifecycleRequest(t, s, q.key, q.method, q.path, q.body)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("public metadata %s=%d", q.method, w.Code)
|
||||
}
|
||||
for _, secret := range []string{"fixture-key", "fixture-token", "fixture-flat-token", "fixture-schema-token"} {
|
||||
if bytes.Contains(w.Body.Bytes(), []byte(secret)) {
|
||||
t.Fatalf("%s %s leaked a fixture credential", q.method, q.path)
|
||||
}
|
||||
}
|
||||
if !bytes.Contains(w.Body.Bytes(), []byte(`"rdm.projectId":"canonical"`)) || !bytes.Contains(w.Body.Bytes(), []byte(`"rdm.state":"ready"`)) {
|
||||
t.Fatal("service metadata lost")
|
||||
}
|
||||
}
|
||||
owned, err := read()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if owned.Settings["models.chat.apiKey"] != "fixture-key" || owned.Settings["custom"].(map[string]any)["token"] != "fixture-token" {
|
||||
t.Fatal("public metadata masking changed stored credentials")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericProjectPatchRejectsManagedSettingsAtomically(t *testing.T) {
|
||||
for _, registered := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(registered), func(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Original")
|
||||
s.set.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.managed", Scope: core.SettingScopeSystem}})
|
||||
if registered {
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
|
||||
current, err := st.GetProject("alice", p.ID)
|
||||
return []core.Project{current}, err
|
||||
}, Rename: func(_ context.Context, _ extension.Host, id, name string) (core.Project, error) {
|
||||
if err := st.RenameProject("alice", id, name); err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
return st.GetProject("alice", id)
|
||||
}}, nil)
|
||||
}
|
||||
for _, body := range []string{`{"name":"Changed","settings":{"models":{"chat":{"apiKey":"fixture-key"}},"display":{"caption":"Forbidden partial write"}}}`, `{"settings":{"models.chat.apiKey":"fixture-key"}}`, `{"settings":{"models":"smash"}}`, `{"settings":{"plugins.private.managed":"override"}}`} {
|
||||
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, body)
|
||||
if w.Code != 400 {
|
||||
t.Fatalf("managed patch=%d", w.Code)
|
||||
}
|
||||
current, err := st.GetProject("alice", p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current.Name != "Original" || len(current.Settings) != 0 {
|
||||
t.Fatal("rejected managed patch applied a partial mutation")
|
||||
}
|
||||
}
|
||||
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, `{"settings":{"custom.caption":"Allowed"}}`)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("allowed patch=%d", w.Code)
|
||||
}
|
||||
current, err := st.GetProject("alice", p.ID)
|
||||
if err != nil || current.Settings["custom.caption"] != "Allowed" {
|
||||
t.Fatal("allowed metadata shape changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericProjectPatchAcceptsNestedMaskedWriteback(t *testing.T) {
|
||||
for _, registered := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(registered), func(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
if err := st.UpdateProjectSettings("alice", p.ID, map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "fixture-persisted-key"}}, "display": map[string]any{"caption": "Original"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if registered {
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
|
||||
current, err := st.GetProject("alice", p.ID)
|
||||
return []core.Project{current}, err
|
||||
}}, nil)
|
||||
}
|
||||
for _, tc := range []struct{ body, caption string }{
|
||||
{`{"settings":{"models":{"chat":{"apiKey":"••••••"}},"display":{"caption":"Nested updated"}}}`, "Nested updated"},
|
||||
{`{"settings":{"models.chat":{"apiKey":"••••••"},"display":{"caption":"Mixed updated"}}}`, "Mixed updated"},
|
||||
{`{"settings":{"models":{"chat.apiKey":"••••••"},"display":{"caption":"Mixed leaf updated"}}}`, "Mixed leaf updated"},
|
||||
} {
|
||||
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, tc.body)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("masked writeback rejected legal edit: %d", w.Code)
|
||||
}
|
||||
current, err := st.GetProject("alice", p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current.Settings["models"].(map[string]any)["chat"].(map[string]any)["apiKey"] != "fixture-persisted-key" || current.Settings["display"].(map[string]any)["caption"] != tc.caption {
|
||||
t.Fatalf("masked writeback secret preserved=%v caption=%q expected=%q", current.Settings["models"].(map[string]any)["chat"].(map[string]any)["apiKey"] == "fixture-persisted-key", current.Settings["display"].(map[string]any)["caption"], tc.caption)
|
||||
}
|
||||
}
|
||||
for _, body := range []string{`{"settings":{"models":{}}}`, `{"settings":{"models":{"chat":{}}}}`, `{"settings":{"models.chat":{}}}`, `{"settings":{"models":{"chat":{"apiKey":"••••••","model":"Overwrite"}}}}`} {
|
||||
if w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, body); w.Code != 400 {
|
||||
t.Fatalf("explicit empty/real managed write accepted: %d", w.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func lifecycleUpload(t *testing.T, s *Server, key, projectID, name, content string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
file, err := writer.CreateFormFile("file", name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := file.Write([]byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest("POST", "/api/projects/"+projectID+"/uploads", &body)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestProjectLifecycleMemberUploadStaysPrivate(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
mallory, _ := st.CreateAPIKey("mallory", "test")
|
||||
p, _ := st.CreateProject("alice", "Shared")
|
||||
ready := true
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
|
||||
return ready && id == p.ID && (actor == "alice" || actor == "bob"), nil
|
||||
}}, nil)
|
||||
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
|
||||
if q.ResourceType == "artifact" {
|
||||
return q.Actor == q.Owner, nil
|
||||
}
|
||||
return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || q.Actor == "bob"), nil
|
||||
})
|
||||
s.DisableUploadIndexing = true
|
||||
w := lifecycleUpload(t, s, bob, p.ID, "member-private.txt", "QA_MEMBER_PRIVATE_ORIGINAL")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("member upload=%d", w.Code)
|
||||
}
|
||||
var info core.UploadInfo
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
artifact, err := st.GetArtifact("bob", info.ArtifactID)
|
||||
if err != nil || artifact.ProjectID != p.ID || artifact.Owner != "bob" {
|
||||
t.Fatalf("upload ownership/target incorrect: %+v %v", artifact, err)
|
||||
}
|
||||
if w := lifecycleRequest(t, s, bob, "GET", "/api/artifacts/"+info.ArtifactID+"/content", ""); w.Code != 200 || w.Body.String() != "QA_MEMBER_PRIVATE_ORIGINAL" {
|
||||
t.Fatalf("owner original read=%d", w.Code)
|
||||
}
|
||||
for _, key := range []string{alice, mallory} {
|
||||
for _, suffix := range []string{"", "/content"} {
|
||||
if w := lifecycleRequest(t, s, key, "GET", "/api/artifacts/"+info.ArtifactID+suffix, ""); w.Code != 404 {
|
||||
t.Fatalf("other actor read private original=%d", w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
if w := lifecycleUpload(t, s, mallory, p.ID, "forbidden.txt", "DENIED"); w.Code != 404 {
|
||||
t.Fatalf("nonmember upload=%d", w.Code)
|
||||
}
|
||||
ready = false
|
||||
if w := lifecycleUpload(t, s, bob, p.ID, "revoked.txt", "DENIED"); w.Code != 404 {
|
||||
t.Fatalf("revoked upload=%d", w.Code)
|
||||
}
|
||||
artifacts, err := st.ListProjectArtifactsAll(p.ID)
|
||||
if err != nil || len(artifacts) != 1 {
|
||||
t.Fatalf("denied upload wrote artifacts: count=%d error=%v", len(artifacts), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectLifecycleUploadSuppressesPrivateProjectCards(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
p, _ := st.CreateProject("alice", "Shared")
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
|
||||
return id == p.ID && (actor == "alice" || actor == "bob"), nil
|
||||
}}, nil)
|
||||
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
|
||||
return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || q.Actor == "bob"), nil
|
||||
})
|
||||
s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent)
|
||||
s.Cards.SetProjectSharedEmitter(s.mgr.EmitProjectSharedCardEvent)
|
||||
s.DisableUploadIndexing = true
|
||||
if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p.ID, Title: "Shared structure", Kind: core.CardKindProgress}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The owner path already admitted upload before the member authorization fix.
|
||||
if w := lifecycleUpload(t, s, alice, p.ID, "owner-private-filename.txt", "PRIVATE ORIGINAL"); w.Code != 200 {
|
||||
t.Fatalf("owner upload=%d", w.Code)
|
||||
}
|
||||
if w := lifecycleUpload(t, s, bob, p.ID, "member-private-filename.txt", "MEMBER PRIVATE ORIGINAL"); w.Code != 200 {
|
||||
t.Fatalf("member upload=%d", w.Code)
|
||||
}
|
||||
for _, owner := range []string{"alice", "bob"} {
|
||||
cards, err := st.ListProjectCards(owner, p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := 0
|
||||
if owner == "alice" {
|
||||
expected = 1
|
||||
}
|
||||
if len(cards) != expected {
|
||||
t.Fatal("private upload added project card history")
|
||||
}
|
||||
for _, card := range cards {
|
||||
if card.Title != "Shared structure" {
|
||||
t.Fatal("private filename/parser card entered project history")
|
||||
}
|
||||
}
|
||||
}
|
||||
events, err := st.EventsAfter(core.ProjectChannelKey(p.ID), 0, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatal("private upload added project events")
|
||||
}
|
||||
for _, event := range events {
|
||||
if bytes.Contains(event.Payload, []byte("owner-private-filename.txt")) || bytes.Contains(event.Payload, []byte("member-private-filename.txt")) {
|
||||
t.Fatal("private upload metadata entered persisted project stream")
|
||||
}
|
||||
}
|
||||
request := httptest.NewRequest("GET", "/api/events?scope=project&projectId="+p.ID, nil)
|
||||
request.Header.Set("Authorization", "Bearer "+bob)
|
||||
ctx, cancel := context.WithTimeout(request.Context(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
request = request.WithContext(ctx)
|
||||
response := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(response, request)
|
||||
if response.Code != 200 || !bytes.Contains(response.Body.Bytes(), []byte("Shared structure")) {
|
||||
t.Fatalf("member structural stream did not replay: %d", response.Code)
|
||||
}
|
||||
if bytes.Contains(response.Body.Bytes(), []byte("owner-private-filename.txt")) || bytes.Contains(response.Body.Bytes(), []byte("member-private-filename.txt")) {
|
||||
t.Fatal("another member's SSE exposed private upload metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnregisteredUploadKeepsLegacyCardsAndValidation(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Private")
|
||||
s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent)
|
||||
s.DisableUploadIndexing = true
|
||||
if w := lifecycleUpload(t, s, key, p.ID, "legacy-upload.txt", "Original"); w.Code != 200 {
|
||||
t.Fatalf("unregistered owner upload=%d", w.Code)
|
||||
}
|
||||
cards, err := st.ListProjectCards("alice", p.ID)
|
||||
if err != nil || len(cards) != 1 || cards[0].Title != "legacy-upload.txt" {
|
||||
t.Fatalf("unregistered ingestion card changed: count=%d error=%v", len(cards), err)
|
||||
}
|
||||
if w := lifecycleRequest(t, s, key, "POST", "/api/projects/missing/uploads", "not multipart"); w.Code != 400 {
|
||||
t.Fatalf("legacy missing-file validation changed: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectLifecycleUploadRechecksBeforePersistence(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
p, _ := st.CreateProject("alice", "Shared")
|
||||
ready := true
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) {
|
||||
allowed := ready
|
||||
ready = false
|
||||
return allowed, nil
|
||||
}}, nil)
|
||||
if w := lifecycleUpload(t, s, key, p.ID, "revoked-during-upload.txt", "PRIVATE"); w.Code != 404 {
|
||||
t.Fatalf("upload persisted after authorization changed: %d", w.Code)
|
||||
}
|
||||
artifacts, err := st.ListProjectArtifactsAll(p.ID)
|
||||
if err != nil || len(artifacts) != 0 {
|
||||
t.Fatal("revoked upload created an artifact")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKnowledgeV2UsesDocumentIdentity(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
docs := []core.KnowledgeDoc{{ID: "published-slice", Owner: "alice", ProjectID: "domain-p", Chunk: "sharedneedle published"}, {ID: "private-draft", Owner: "alice", ProjectID: "domain-p", Chunk: "sharedneedle draft"}, {ID: "self-denied", Owner: "bob", ProjectID: "domain-p", Chunk: "sharedneedle own restricted"}, {ID: "self-allowed", Owner: "bob", ProjectID: "domain-p", Chunk: "sharedneedle own"}}
|
||||
if err := st.AddKnowledge(docs...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
|
||||
seen := map[string]extension.ResourceAuthzQuery{}
|
||||
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
|
||||
seen[q.ResourceID] = q
|
||||
return q.ResourceType == "project" || q.ResourceID == "published-slice" || q.ResourceID == "self-allowed", nil
|
||||
})
|
||||
w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=sharedneedle", "")
|
||||
var hits []core.RetrievalHit
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &hits)
|
||||
if w.Code != 200 || len(hits) != 2 {
|
||||
t.Fatalf("V2 filtered hits=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
for _, hit := range hits {
|
||||
if hit.Doc.ID != "published-slice" && hit.Doc.ID != "self-allowed" {
|
||||
t.Fatalf("private leak: %+v", hit.Doc)
|
||||
}
|
||||
}
|
||||
q := seen["published-slice"]
|
||||
if q.ResourceType != "knowledge" || q.Actor != "bob" || q.Action != "search" || q.Owner != "alice" || q.ProjectID != "domain-p" {
|
||||
t.Fatalf("trusted doc query: %+v", q)
|
||||
}
|
||||
}
|
||||
func TestArtifactListV2FiltersEachResource(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
p, _ := st.CreateProject("alice", "Project")
|
||||
private := core.Artifact{Owner: "alice", ProjectID: p.ID, Name: "Private", MIME: "text/plain"}
|
||||
public := core.Artifact{Owner: "alice", ProjectID: p.ID, Name: "Shared", MIME: "text/plain"}
|
||||
if err := st.CreateArtifact(&private, []byte("private")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateArtifact(&public, []byte("shared")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
|
||||
return q.ResourceType == "project" || (q.ResourceType == "artifact" && q.Action == "list" && q.ResourceID == public.ID && q.Owner == "alice"), nil
|
||||
})
|
||||
w := lifecycleRequest(t, s, key, "GET", "/api/projects/"+p.ID+"/artifacts", "")
|
||||
var as []core.Artifact
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &as)
|
||||
if w.Code != 200 || len(as) != 1 || as[0].ID != public.ID {
|
||||
t.Fatalf("artifact list=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeV2ResolvesTrustedProjectScope(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "alice", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "alice", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { return q.ResourceType == "knowledge", nil })
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
|
||||
return []core.Project{{ID: "host-current", Settings: map[string]any{"rdm.projectId": "domain-current", "rdm.state": "ready"}}, {ID: "host-other", Settings: map[string]any{"rdm.projectId": "domain-other", "rdm.state": "ready"}}}, nil
|
||||
}}, nil)
|
||||
w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=scopeneedle&project=host-current", "")
|
||||
var hits []core.RetrievalHit
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &hits)
|
||||
if w.Code != 200 || len(hits) != 1 || hits[0].Doc.ID != "slice-current" {
|
||||
t.Fatalf("current scope=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=scopeneedle&project=unknown", ""); w.Code != 404 {
|
||||
t.Fatalf("unknown scope=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeV2UsesStoredPublicationSource(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("bob", "test")
|
||||
// A known published identity replaced by a private row must lose sharing.
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "known-published-id", Owner: "alice", ProjectID: "domain", Source: "manual", Chunk: "sourceneedle private"}, core.KnowledgeDoc{ID: "real-published-id", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Chunk: "sourceneedle public"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
|
||||
return q.ResourceType == "knowledge" && q.Source == "plugin:rdm:published", nil
|
||||
})
|
||||
w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=sourceneedle", "")
|
||||
var hits []core.RetrievalHit
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &hits)
|
||||
if w.Code != 200 || len(hits) != 1 || hits[0].Doc.ID != "real-published-id" {
|
||||
t.Fatalf("actual stored source=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package server
|
||||
|
||||
// EXT-8b 读面消费红测(方案 r1 U9 第二切片):五处共享读路径在 V2 注册后按
|
||||
// 资源级查询咨询(Actor/Action/ResourceType/ResourceID/Owner/ProjectID 由
|
||||
// 受信上下文构造);V2 拒绝压过 V1 放行(资源级权威);V2 未注册时五处全部
|
||||
// 回落 V1(兼容现行,RDM 现网行为不变)。红:当前 server 尚无 V2 槽位与
|
||||
// AuthorizeResource 消费——注册接口不存在,编译红先行。
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func newV2CaptureServer(t *testing.T) (*Server, *store.Store, string, string, string) {
|
||||
t.Helper()
|
||||
srv, st := newAuthTestServer(t)
|
||||
alice, err := st.CreateAPIKey("alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bob, err := st.CreateAPIKey("bob", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pj, err := st.CreateProject("alice", "资源级项目")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return srv, st, alice, bob, pj.ID
|
||||
}
|
||||
|
||||
func TestSharedReadPathsConsultV2WithResourceContext(t *testing.T) {
|
||||
srv, st, alice, bob, pid := newV2CaptureServer(t)
|
||||
_ = alice
|
||||
|
||||
// 项目内造一个工件(供 download 路径取元数据)。
|
||||
art := &core.Artifact{ProjectID: core.ID(pid), Owner: "alice", Name: "res.txt", Kind: "file", MIME: "text/plain"}
|
||||
if err := st.CreateArtifact(art, []byte("body")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got []extension.ResourceAuthzQuery
|
||||
srv.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
|
||||
got = append(got, q)
|
||||
return true, nil
|
||||
})
|
||||
h := srv.Handler()
|
||||
call := func(method, path string) int {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+bob)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
// ① 工件下载(owner 自有未命中 → 资源级咨询)。
|
||||
code := call(http.MethodGet, "/api/artifacts/"+string(art.ID)+"/content?download=1")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("download: %d", code)
|
||||
}
|
||||
// ② 项目工件清单。
|
||||
call(http.MethodGet, "/api/projects/"+pid+"/artifacts")
|
||||
// ③ 共享项目建会话。
|
||||
call(http.MethodPost, "/api/projects/"+pid+"/conversations")
|
||||
|
||||
byAction := map[string]extension.ResourceAuthzQuery{}
|
||||
for _, q := range got {
|
||||
byAction[q.Action] = q
|
||||
}
|
||||
dl := byAction["download"]
|
||||
if dl.Actor != "bob" || dl.ResourceType != "artifact" || dl.ResourceID != art.ID || dl.ProjectID != pid || dl.Owner != "alice" {
|
||||
t.Fatalf("download 查询六要素不符: %+v", dl)
|
||||
}
|
||||
ls := byAction["list"]
|
||||
if ls.Actor != "bob" || ls.ResourceType != "artifact" || ls.ResourceID != art.ID || ls.ProjectID != pid || ls.Owner != "alice" {
|
||||
t.Fatalf("list 查询不符: %+v", ls)
|
||||
}
|
||||
rd := byAction["read"]
|
||||
if rd.Actor != "bob" || rd.ResourceType != "project" || rd.ResourceID != pid {
|
||||
t.Fatalf("read(conversation) 查询不符: %+v", rd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2DenyOverridesV1Allow(t *testing.T) {
|
||||
srv, st, alice, bob, pid := newV2CaptureServer(t)
|
||||
_ = alice
|
||||
art := &core.Artifact{ProjectID: core.ID(pid), Owner: "alice", Name: "d.txt", Kind: "file"}
|
||||
if err := st.CreateArtifact(art, []byte("body")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// V1 全放行、V2 全拒绝——资源级权威必须压过 V1。
|
||||
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
|
||||
return true, nil
|
||||
})
|
||||
srv.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
|
||||
return false, nil
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/artifacts/"+string(art.ID)+"/content?download=1", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+bob)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("V2 拒绝须压过 V1 放行并保持 404(不泄露存在性), got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2AbsentFallsBackToV1(t *testing.T) {
|
||||
srv, st, alice, bob, pid := newV2CaptureServer(t)
|
||||
_ = alice
|
||||
art := &core.Artifact{ProjectID: core.ID(pid), Owner: "alice", Name: "f.txt", Kind: "file"}
|
||||
if err := st.CreateArtifact(art, []byte("body")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
|
||||
return owner == "bob" && string(projectID) == pid, nil
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/artifacts/"+string(art.ID)+"/content?download=1", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+bob)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("V2 未注册应回落 V1(现行行为), got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
+455
-63
@@ -26,6 +26,7 @@ import (
|
||||
"agent-system-backend/internal/intentgate"
|
||||
"agent-system-backend/internal/plugins"
|
||||
"agent-system-backend/internal/profiles"
|
||||
"agent-system-backend/internal/projectscope"
|
||||
"agent-system-backend/internal/prompts"
|
||||
"agent-system-backend/internal/ratelimit"
|
||||
"agent-system-backend/internal/route"
|
||||
@@ -41,24 +42,26 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
st *store.Store
|
||||
Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入)
|
||||
Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入)
|
||||
Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入)
|
||||
Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入)
|
||||
LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入)
|
||||
mgr *runmgr.Manager
|
||||
Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入)
|
||||
Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17)
|
||||
Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27)
|
||||
Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接)
|
||||
Workflows *workflow.Registry // 已装载工作流(nil = 不接)
|
||||
Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退)
|
||||
reg *tools.Registry
|
||||
prof *profiles.Service
|
||||
set *settings.Service
|
||||
plugins *plugins.Host
|
||||
static fs.FS
|
||||
projectLifecycle *extension.ProjectLifecycle
|
||||
projectHost func(string) extension.Host
|
||||
st *store.Store
|
||||
Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入)
|
||||
Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入)
|
||||
Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入)
|
||||
Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入)
|
||||
LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入)
|
||||
mgr *runmgr.Manager
|
||||
Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入)
|
||||
Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17)
|
||||
Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27)
|
||||
Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接)
|
||||
Workflows *workflow.Registry // 已装载工作流(nil = 不接)
|
||||
Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退)
|
||||
reg *tools.Registry
|
||||
prof *profiles.Service
|
||||
set *settings.Service
|
||||
plugins *plugins.Host
|
||||
static fs.FS
|
||||
// Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。
|
||||
Subscribe func(conversationID string) (<-chan core.StreamEvent, func())
|
||||
// DisableUploadIndexing EXT-9:true 时上传端点不再自动写检索索引(原件与
|
||||
@@ -67,6 +70,9 @@ type Server struct {
|
||||
// ProjectAuthz EXT-8 资源授权适配器(组合根注入;nil=未注册,读取入口
|
||||
// owner 自有判定未命中即 NotFound——与未扩展基座行为一致)。
|
||||
ProjectAuthz func(owner string, projectID core.ID) (bool, error)
|
||||
// ProjectAuthzV2 EXT-8b 资源级授权适配器(U9;组合根注入;nil=未注册,
|
||||
// 读面回落 ProjectAuthz 兼容)。查询六要素由本包受信构造。
|
||||
ProjectAuthzV2 func(q extension.ResourceAuthzQuery) (bool, error)
|
||||
// ProjectAuthzActions 动作级授权适配器(协作存储 U3,KTD13):注册后对
|
||||
// 全部动作权威;未注册时 read 回落 ProjectAuthz(兼容),其余动作一律
|
||||
// 默认拒绝——旧只读适配器不得自动获得写入能力。公开契约的版本化扩展
|
||||
@@ -94,6 +100,11 @@ func (s *Server) SetProjectAuthorizer(fn func(owner string, projectID core.ID) (
|
||||
s.ProjectAuthz = fn
|
||||
}
|
||||
|
||||
// SetResourceAuthorizerV2 组合根在装载扩展后注入 EXT-8b 资源级适配器。
|
||||
func (s *Server) SetResourceAuthorizerV2(fn func(q extension.ResourceAuthzQuery) (bool, error)) {
|
||||
s.ProjectAuthzV2 = fn
|
||||
}
|
||||
|
||||
// SetProjectActionAuthorizer 组合根注入 KTD13 动作级适配器(版本化公开契约
|
||||
// 扩展落地前,宿主侧先以本槽位提供矩阵语义)。
|
||||
func (s *Server) SetProjectActionAuthorizer(fn func(owner string, projectID core.ID, action ProjectAction) (bool, error)) {
|
||||
@@ -110,13 +121,32 @@ func (s *Server) authorizeProjectAction(owner string, projectID core.ID, action
|
||||
return err == nil && ok
|
||||
}
|
||||
if action == ProjectRead {
|
||||
return s.authorizeSharedProject(owner, projectID)
|
||||
return s.authorizeSharedResource(owner, "read", "project", projectID, "", projectID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// authorizeSharedProject 读取路径的共享判定:适配器未注册或拒绝/出错一律 false
|
||||
// (fail-closed;错误不区分,避免泄露存在性)。
|
||||
// authorizeSharedResource EXT-8b 资源级读判定(U9):V2 注册时按六要素咨询
|
||||
// (Actor=认证会话、Action/ResourceType/ResourceID 由调用点受信构造、
|
||||
// Owner/ProjectID 从受信存储元数据解析;出错/字段缺失一律 false,fail-closed
|
||||
// 且不泄露存在性);V2 未注册回落 authorizeSharedProject(现行兼容)。
|
||||
func (s *Server) authorizeSharedResource(actor, action, resourceType string, resourceID, owner, projectID core.ID) bool {
|
||||
if s.ProjectAuthzV2 == nil {
|
||||
return s.authorizeSharedProject(actor, projectID)
|
||||
}
|
||||
if actor == "" || action == "" || resourceType == "" || resourceID == "" {
|
||||
return false
|
||||
}
|
||||
ok, err := s.ProjectAuthzV2(extension.ResourceAuthzQuery{
|
||||
Actor: actor, Action: action, ResourceType: resourceType,
|
||||
ResourceID: resourceID, Owner: owner, ProjectID: projectID,
|
||||
})
|
||||
return err == nil && ok
|
||||
}
|
||||
|
||||
// authorizeSharedProject 读取路径的共享判定(V1):适配器未注册或拒绝/出错一律
|
||||
// false(fail-closed;错误不区分,避免泄露存在性)。EXT-8b 落地后仅作 V2 缺席
|
||||
// 时的兼容回落——新读面一律走 authorizeSharedResource。
|
||||
func (s *Server) authorizeSharedProject(owner string, projectID core.ID) bool {
|
||||
if s.ProjectAuthz == nil {
|
||||
return false
|
||||
@@ -179,7 +209,7 @@ func (s *Server) WireBuiltinCommands() {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := s.st.CreateConversation(conv.Owner, conv.ProjectID, "新会话", conv.ProfileID)
|
||||
c, err := s.createPrivateConversation(ctx, conv.Owner, conv.ProjectID, "新会话", conv.ProfileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -251,11 +281,15 @@ func (s *Server) Handler() http.Handler {
|
||||
|
||||
// ---- 认证与会话(管理员发放制,R2)----
|
||||
mux.HandleFunc("POST /api/auth/login", s.login)
|
||||
mux.HandleFunc("POST /api/auth/login-key/redeem", s.redeemLoginKey)
|
||||
mux.HandleFunc("POST /api/auth/logout", s.logout)
|
||||
mux.HandleFunc("POST /api/auth/password", s.changePassword)
|
||||
mux.HandleFunc("GET /api/admin/users", s.listAdminUsers)
|
||||
mux.HandleFunc("POST /api/admin/users", s.createAdminUser)
|
||||
mux.HandleFunc("POST /api/admin/users/{owner}/password", s.resetAdminUserPassword)
|
||||
mux.HandleFunc("POST /api/admin/users/{owner}/login-keys", s.issueAdminLoginKey)
|
||||
mux.HandleFunc("GET /api/admin/users/{owner}/login-keys", s.listAdminLoginKeys)
|
||||
mux.HandleFunc("DELETE /api/admin/users/{owner}/login-keys/{id}", s.revokeAdminLoginKey)
|
||||
|
||||
// ---- 项目 / 会话 ----
|
||||
mux.HandleFunc("GET /api/projects", s.listProjects)
|
||||
@@ -321,6 +355,8 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/settings", s.getSettings)
|
||||
mux.HandleFunc("PUT /api/settings/user", s.putSettingsUser)
|
||||
mux.HandleFunc("PUT /api/settings/project/{id}", s.putSettingsProject)
|
||||
mux.HandleFunc("GET /api/settings/system", s.getSettingsSystem)
|
||||
mux.HandleFunc("PUT /api/settings/system", s.putSettingsSystem)
|
||||
mux.HandleFunc("GET /api/tools", s.listTools)
|
||||
mux.HandleFunc("GET /api/url-preview", s.urlPreview)
|
||||
mux.HandleFunc("POST /api/tools", s.createUserTool)
|
||||
@@ -387,9 +423,15 @@ func (s *Server) Handler() http.Handler {
|
||||
|
||||
const sseRetryMillis = 1000
|
||||
|
||||
// sseAuthRecheck:会话凭证的 SSE 长连接复验周期。密码写入(恢复/改密/重置)
|
||||
// 会删除全部会话并 bump auth_version;已建立的流最多在此窗口内继续收事件,
|
||||
// 心跳点复验失效即断流(EventSource 重连时由中间件拒绝)。测试可缩短。
|
||||
var sseAuthRecheck = 15 * time.Second
|
||||
|
||||
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
conversationID := r.URL.Query().Get("conversationId")
|
||||
isProject := false
|
||||
streamProject := ""
|
||||
// 项目流(D50):scope=project&projectId=<id> 订阅项目频道——伪会话键
|
||||
// "project:<id>" 复用会话流的持久化/seq/重放机制;租户边界按项目校验。
|
||||
if r.URL.Query().Get("scope") == "project" {
|
||||
@@ -399,14 +441,15 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if _, err := s.st.GetProject(s.owner(r), pid); err != nil {
|
||||
// EXT-8 切片 2:owner 自有未命中时咨询适配器(放行才可订阅项目流)。
|
||||
if !s.authorizeSharedProject(s.owner(r), core.ID(pid)) {
|
||||
// EXT-8b:资源级咨询(stream×project);V2 缺席回落 V1。
|
||||
if !s.authorizeSharedResource(s.owner(r), "stream", "project", core.ID(pid), "", core.ID(pid)) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
conversationID = string(core.ProjectChannelKey(core.ID(pid)))
|
||||
isProject = true
|
||||
streamProject = pid
|
||||
} else if conversationID == "" {
|
||||
http.Error(w, "conversationId required", http.StatusBadRequest)
|
||||
return
|
||||
@@ -438,45 +481,96 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
since, _ = strconv.ParseInt(q, 10, 64)
|
||||
}
|
||||
|
||||
// 长连接凭证复验(login 恢复合同 §6.7):会话凭证查会话行(密码写入即删除);
|
||||
// API key 凭证查撤销位(RevokeAllAPIKeys/失陷撤销后断流)。两种凭证都在
|
||||
// 复验周期内核验属主未变。
|
||||
sessionTok, keyCred := "", ""
|
||||
switch AuthVia(r) {
|
||||
case AuthViaSession:
|
||||
sessionTok = sessionToken(r)
|
||||
case AuthViaKey:
|
||||
keyCred = bearerKey(r)
|
||||
if keyCred == "" {
|
||||
keyCred = r.URL.Query().Get("key")
|
||||
}
|
||||
}
|
||||
wantOwner := s.owner(r)
|
||||
stillAuthed := func() bool {
|
||||
if sessionTok != "" {
|
||||
o, _, ok := s.st.SessionInfo(sessionTok)
|
||||
return ok && o == wantOwner
|
||||
}
|
||||
if keyCred != "" {
|
||||
o, ok := s.st.AuthOwner(keyCred)
|
||||
return ok && o == wantOwner
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 先订阅再重放,避免重放与订阅间隙丢事件;按 seq 去重。
|
||||
live, stop := s.Subscribe(conversationID)
|
||||
defer stop()
|
||||
|
||||
lastVisible := since
|
||||
send := func(e core.StreamEvent) bool {
|
||||
if e.Seq <= lastVisible {
|
||||
return true
|
||||
}
|
||||
if isProject && !s.projectEventVisible(wantOwner, e) {
|
||||
return r.Context().Err() == nil
|
||||
}
|
||||
buf, _ := json.Marshal(e)
|
||||
fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", e.Seq, e.Type, buf)
|
||||
flusher.Flush()
|
||||
lastVisible = e.Seq
|
||||
return r.Context().Err() == nil
|
||||
}
|
||||
|
||||
lastSent := since
|
||||
// Seen progresses even when a private frame is filtered. Visible only tracks
|
||||
// frames delivered to this actor, preserving authoritative seq and resumption.
|
||||
lastSeen := since
|
||||
for {
|
||||
replay, err := s.st.EventsAfter(conversationID, lastSent, 1000)
|
||||
replay, err := s.st.EventsAfter(conversationID, lastSeen, 1000)
|
||||
if err != nil || len(replay) == 0 {
|
||||
break
|
||||
}
|
||||
for _, e := range replay {
|
||||
if e.Seq <= lastSent {
|
||||
if e.Seq <= lastSeen {
|
||||
continue
|
||||
}
|
||||
if !send(e) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
lastSent = e.Seq
|
||||
lastSeen = e.Seq
|
||||
}
|
||||
if len(replay) < 1000 {
|
||||
break
|
||||
}
|
||||
}
|
||||
// 心跳 + 在线转发。通道关闭表示缓冲溢出,结束响应让 EventSource 带 Last-Event-ID 重连。
|
||||
// 会话凭证在心跳点复验:失效(改密/恢复/重置已删会话)即终止响应。
|
||||
heartbeat := time.NewTicker(15 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
recheck := time.NewTicker(sseAuthRecheck)
|
||||
defer recheck.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
stop()
|
||||
return
|
||||
case <-recheck.C:
|
||||
if isProject && s.projectLifecycle != nil && !s.projectStreamMembership(wantOwner, streamProject) {
|
||||
fmt.Fprint(w, ": project-access-revoked\n\n")
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
if !stillAuthed() {
|
||||
fmt.Fprint(w, ": session-expired\n\n")
|
||||
flusher.Flush()
|
||||
stop()
|
||||
return
|
||||
}
|
||||
case <-heartbeat.C:
|
||||
fmt.Fprint(w, ": ping\n\n")
|
||||
flusher.Flush()
|
||||
@@ -485,14 +579,19 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
if e.ConversationID != conversationID || e.Seq <= lastSent {
|
||||
if e.ConversationID != conversationID || e.Seq <= lastSeen {
|
||||
continue
|
||||
}
|
||||
if isProject && s.projectLifecycle != nil && !s.projectStreamMembership(wantOwner, streamProject) {
|
||||
fmt.Fprint(w, ": project-access-revoked\n\n")
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
if !send(e) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
lastSent = e.Seq
|
||||
lastSeen = e.Seq
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -538,6 +637,23 @@ type projectView struct {
|
||||
|
||||
func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) {
|
||||
owner := s.owner(r)
|
||||
if s.projectLifecycle != nil {
|
||||
if s.projectLifecycle.List == nil {
|
||||
writeProjectErr(w, store.ErrNotFound)
|
||||
return
|
||||
}
|
||||
ps, err := s.projectLifecycle.List(r.Context(), s.lifecycleHost(owner))
|
||||
if err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]projectView, 0, len(ps))
|
||||
for _, p := range ps {
|
||||
out = append(out, projectView{Project: s.publicProjectMetadata(p), Shared: p.Owner != owner})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
ps, err := s.st.ListProjects(owner)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
@@ -574,6 +690,15 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if s.projectLifecycle != nil {
|
||||
if s.projectLifecycle.Create == nil {
|
||||
writeProjectErr(w, store.ErrNotFound)
|
||||
return
|
||||
}
|
||||
p, err := s.projectLifecycle.Create(r.Context(), s.lifecycleHost(s.owner(r)), strings.TrimSpace(req.Name))
|
||||
writeProjectJSON(w, s.publicProjectMetadata(p), err)
|
||||
return
|
||||
}
|
||||
p, err := s.st.CreateProject(s.owner(r), strings.TrimSpace(req.Name))
|
||||
writeJSONOr(w, p, err)
|
||||
}
|
||||
@@ -588,6 +713,58 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Settings != nil {
|
||||
values, err := s.set.PrepareProjectMetadataWrite(req.Settings)
|
||||
if err != nil {
|
||||
s.writeSettingsMerge(w, func() error { return err })
|
||||
return
|
||||
}
|
||||
req.Settings = values
|
||||
}
|
||||
if s.projectLifecycle != nil {
|
||||
var p core.Project
|
||||
var err error
|
||||
if req.Name != nil {
|
||||
if strings.TrimSpace(*req.Name) == "" {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if s.projectLifecycle.Rename == nil {
|
||||
writeProjectErr(w, store.ErrNotFound)
|
||||
return
|
||||
}
|
||||
p, err = s.projectLifecycle.Rename(r.Context(), s.lifecycleHost(s.owner(r)), id, strings.TrimSpace(*req.Name))
|
||||
if err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Settings != nil {
|
||||
if err := s.st.UpdateProjectSettings(s.owner(r), id, req.Settings); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Name == nil || req.Settings != nil {
|
||||
if s.projectLifecycle.List == nil {
|
||||
writeProjectErr(w, store.ErrNotFound)
|
||||
return
|
||||
}
|
||||
var projects []core.Project
|
||||
projects, err = s.projectLifecycle.List(r.Context(), s.lifecycleHost(s.owner(r)))
|
||||
if err == nil {
|
||||
err = store.ErrNotFound
|
||||
for _, candidate := range projects {
|
||||
if candidate.ID == id {
|
||||
p, err = candidate, nil
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
writeProjectJSON(w, s.publicProjectMetadata(p), err)
|
||||
return
|
||||
}
|
||||
if req.Name != nil {
|
||||
if err := s.st.RenameProject(s.owner(r), id, *req.Name); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
@@ -605,12 +782,29 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
if s.projectLifecycle != nil {
|
||||
if s.projectLifecycle.Archive == nil {
|
||||
writeProjectErr(w, store.ErrNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.projectLifecycle.Archive(r.Context(), s.lifecycleHost(s.owner(r)), r.PathValue("id")); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
writeOKOr(w, s.st.DeleteProject(s.owner(r), r.PathValue("id")))
|
||||
}
|
||||
|
||||
func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) {
|
||||
owner := s.owner(r)
|
||||
if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil {
|
||||
if s.projectLifecycle != nil {
|
||||
if err := s.projectWork(r.Context(), owner, r.PathValue("id")); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
} else if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -634,9 +828,13 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
||||
name = u.Name
|
||||
}
|
||||
out := map[string]any{"owner": owner, "name": name, "auth": s.Auth != nil && s.Auth.Enabled, "authVia": AuthVia(r)}
|
||||
if sc := SessionScope(r); sc != "" {
|
||||
out["sessionScope"] = sc
|
||||
}
|
||||
if st, err := s.st.GetUserAuthState(owner); err == nil {
|
||||
out["isAdmin"] = st.IsAdmin
|
||||
out["mustChangePassword"] = st.MustChangePassword
|
||||
out["passwordSet"] = st.HasPassword
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -650,13 +848,8 @@ func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Title == "" {
|
||||
req.Title = "新会话"
|
||||
}
|
||||
c, err := s.st.CreateConversation(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID)
|
||||
// EXT-15:自有未命中且适配器放行(成员视角的共享项目)→ 会话仍归属创建者,
|
||||
// 只是落在他人项目下;撤销授权后此路径立即 404(不泄露存在性)。
|
||||
if errors.Is(err, store.ErrNotFound) && s.authorizeSharedProject(s.owner(r), core.ID(r.PathValue("id"))) {
|
||||
c, err = s.st.CreateConversationInProject(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID)
|
||||
}
|
||||
writeJSONOr(w, c, err)
|
||||
c, err := s.createPrivateConversation(r.Context(), s.owner(r), r.PathValue("id"), req.Title, req.ProfileID)
|
||||
writeProjectJSON(w, c, err)
|
||||
}
|
||||
|
||||
// conversationDetail 终态对账权威:消息树 + runs + 活动 HITL 卡片。
|
||||
@@ -735,6 +928,10 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.projectWork(r.Context(), owner, conv.ProjectID); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Text string `json:"text"`
|
||||
AttachmentIDs []string `json:"attachmentIds"`
|
||||
@@ -890,7 +1087,7 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if startErr != nil {
|
||||
s.mgr.ReleaseConversation(convID)
|
||||
writeStoreErr(w, startErr)
|
||||
writeProjectErr(w, startErr)
|
||||
return
|
||||
}
|
||||
_ = s.st.SetMessageRunID(msg.ID, run.ID)
|
||||
@@ -995,10 +1192,21 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var hostProject string
|
||||
if body.ConversationID != "" {
|
||||
if conv, err := s.st.GetConversation(s.owner(r), body.ConversationID); err == nil {
|
||||
conv, err := s.st.GetConversation(s.owner(r), body.ConversationID)
|
||||
if err != nil {
|
||||
if s.projectLifecycle != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
hostProject = string(conv.ProjectID)
|
||||
if err := s.projectWork(r.Context(), s.owner(r), hostProject); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{
|
||||
ID: r.PathValue("id"), Params: body.Params, Entrance: body.Entrance, Actor: s.owner(r),
|
||||
Owner: s.owner(r), ConversationID: body.ConversationID, AnchorMessageID: body.AnchorMessageID,
|
||||
@@ -1077,6 +1285,22 @@ func (s *Server) resolveHITL(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
resp.CardID = r.PathValue("id")
|
||||
if s.projectLifecycle != nil {
|
||||
card, err := s.st.GetHITLCard(s.owner(r), resp.CardID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
conv, err := s.st.GetConversation(s.owner(r), card.ConversationID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.projectWork(r.Context(), s.owner(r), conv.ProjectID); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
pivotText, pivoted, err := s.mgr.ResolveHITL(s.owner(r), string(resp.CardID), resp)
|
||||
if err != nil && s.st.Dialect() == "postgres" {
|
||||
// 跨实例 HITL(U5/KTD5):本地无执行句柄——写持久命令,执行实例
|
||||
@@ -1143,6 +1367,28 @@ func (s *Server) cardToProject(w http.ResponseWriter, r *http.Request) {
|
||||
// cardAction 处理卡上按钮:resolved + 回执消息进对话时间线(确认即留痕)。
|
||||
func (s *Server) cardAction(w http.ResponseWriter, r *http.Request) {
|
||||
owner := s.owner(r)
|
||||
if s.projectLifecycle != nil {
|
||||
card, err := s.st.GetCard(owner, r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
pid := card.ProjectID
|
||||
if card.ConversationID != "" {
|
||||
conv, err := s.st.GetConversation(owner, card.ConversationID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
pid = conv.ProjectID
|
||||
}
|
||||
if pid != "" {
|
||||
if err := s.projectWork(r.Context(), owner, pid); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
var body struct {
|
||||
FreeText string `json:"freeText,omitempty"`
|
||||
}
|
||||
@@ -1189,6 +1435,17 @@ func (s *Server) cardDismiss(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := r.PathValue("id")
|
||||
owner := s.owner(r)
|
||||
if s.projectLifecycle != nil {
|
||||
if err := s.projectWork(r.Context(), owner, projectID); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
if _, err := s.st.ProjectMetadata(projectID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
r.ParseMultipartForm(64 << 20)
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
@@ -1201,10 +1458,18 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
owner := s.owner(r)
|
||||
if _, err := s.st.GetProject(owner, projectID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
if s.projectLifecycle == nil {
|
||||
if _, err := s.st.GetProject(owner, projectID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if s.projectLifecycle != nil {
|
||||
// Request-body reading can outlast the membership/ready decision.
|
||||
if err := s.projectWork(r.Context(), owner, projectID); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
mime := header.Header.Get("Content-Type")
|
||||
art := core.Artifact{ProjectID: projectID, Owner: owner, Name: header.Filename, Kind: kindOf(header.Filename), MIME: mime}
|
||||
@@ -1219,11 +1484,14 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
|
||||
Status: core.CardActive, Tone: "info",
|
||||
Body: []core.UINode{{Type: core.UIProgress, Progress: &core.UIProgressSpec{Label: "解析中", Value: 0.3}}},
|
||||
}
|
||||
if s.Cards != nil {
|
||||
// This endpoint has no private conversation stream. Registered uploads keep
|
||||
// original filename/parser metadata out of shared project cards and events.
|
||||
showIngestCard := s.Cards != nil && s.projectLifecycle == nil
|
||||
if showIngestCard {
|
||||
ingestCard, _ = s.Cards.Create(owner, ingestCard, "")
|
||||
}
|
||||
doc := ingest.Parse(art.Name, mime, content)
|
||||
if s.Cards != nil {
|
||||
if showIngestCard {
|
||||
ingestCard.Status = core.CardDone
|
||||
ingestCard.Tone = "ok"
|
||||
body := []core.UINode{
|
||||
@@ -1267,20 +1535,32 @@ func kindOf(filename string) string {
|
||||
}
|
||||
|
||||
func (s *Server) listArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
owner := s.owner(r)
|
||||
pid := r.PathValue("id")
|
||||
if _, err := s.st.GetProject(owner, pid); err != nil {
|
||||
// EXT-8:owner 自有未命中时问适配器;放行则返回项目全部工件(只读共享)。
|
||||
if !s.authorizeSharedProject(owner, core.ID(pid)) {
|
||||
writeStoreErr(w, err)
|
||||
owner, pid := s.owner(r), r.PathValue("id")
|
||||
var as []core.Artifact
|
||||
var err error
|
||||
if _, ownedErr := s.st.GetProject(owner, pid); ownedErr != nil {
|
||||
if !s.authorizeSharedResource(owner, "list", "project", core.ID(pid), "", core.ID(pid)) {
|
||||
writeStoreErr(w, ownedErr)
|
||||
return
|
||||
}
|
||||
as, err := s.st.ListProjectArtifactsAll(pid)
|
||||
writeJSONOr(w, as, err)
|
||||
as, err = s.st.ListProjectArtifactsAll(pid)
|
||||
} else {
|
||||
as, err = s.st.ListArtifacts(owner, pid)
|
||||
}
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
as, err := s.st.ListArtifacts(owner, pid)
|
||||
writeJSONOr(w, as, err)
|
||||
if s.ProjectAuthzV2 != nil {
|
||||
filtered := make([]core.Artifact, 0, len(as))
|
||||
for _, a := range as {
|
||||
if s.authorizeSharedResource(owner, "list", "artifact", a.ID, a.Owner, a.ProjectID) {
|
||||
filtered = append(filtered, a)
|
||||
}
|
||||
}
|
||||
as = filtered
|
||||
}
|
||||
writeJSON(w, http.StatusOK, as)
|
||||
}
|
||||
|
||||
// sharedArtifactGet EXT-8 工件读取回退:owner 自有未命中 → 取元数据问适配器,
|
||||
@@ -1290,7 +1570,8 @@ func (s *Server) sharedArtifactGet(owner, id string) (core.Artifact, bool) {
|
||||
if err != nil {
|
||||
return core.Artifact{}, false
|
||||
}
|
||||
if !s.authorizeSharedProject(owner, a.ProjectID) {
|
||||
// EXT-8b:工件级六要素(Owner=工件属主,受信元数据解析)。
|
||||
if !s.authorizeSharedResource(owner, "download", "artifact", a.ID, a.Owner, a.ProjectID) {
|
||||
return core.Artifact{}, false
|
||||
}
|
||||
return a, true
|
||||
@@ -1406,7 +1687,7 @@ func (s *Server) putSettingsUser(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeOKOr(w, s.set.MergeUser(s.owner(r), values))
|
||||
s.writeSettingsMerge(w, func() error { return s.set.MergeUser(s.owner(r), values) })
|
||||
}
|
||||
|
||||
func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1415,7 +1696,66 @@ func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeOKOr(w, s.set.MergeProject(s.owner(r), r.PathValue("id"), values))
|
||||
s.writeSettingsMerge(w, func() error {
|
||||
return s.set.MergeProject(s.owner(r), r.PathValue("id"), values)
|
||||
})
|
||||
}
|
||||
|
||||
// writeSettingsMerge 统一处理设置写入结果:触碰系统受管项返回 400 并列出路径,
|
||||
// 其余错误按 writeOKOr 既有语义处理。
|
||||
func (s *Server) writeSettingsMerge(w http.ResponseWriter, merge func() error) {
|
||||
err := merge()
|
||||
if err == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
var mp *settings.ErrManagedPath
|
||||
if errors.As(err, &mp) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeStoreErr(w, err)
|
||||
}
|
||||
|
||||
// ---- 系统设置(管理员;P2 受管项的唯一写入面)----
|
||||
|
||||
// requireSystemAdmin 系统设置面的准入:认证档要求基座管理员;无认证单机档
|
||||
// (Auth 未启用)本机即管理员——该档全部 API 本就无认证,锁系统层只会破坏
|
||||
// 嵌入式部署的配置播种,不增加安全边界。
|
||||
func (s *Server) requireSystemAdmin(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
if s.Auth == nil {
|
||||
return s.owner(r), true
|
||||
}
|
||||
return s.requireAdmin(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) getSettingsSystem(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
schema := append(settings.Schema(), s.plugins.SettingsSchema()...)
|
||||
sys, err := s.set.SystemScope()
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"schema": schema,
|
||||
"defaults": settings.Masked(settings.SystemDefaults(), schema),
|
||||
"system": settings.Masked(sys, schema),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) putSettingsSystem(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
var values map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&values); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeOKOr(w, s.set.MergeSystem(values))
|
||||
}
|
||||
|
||||
// ---- 工具(用户声明式 HTTP 工具注册)----
|
||||
@@ -1560,10 +1900,15 @@ func (s *Server) listAdminZoneSections(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// registry 统一注册表视图(来源标记 + 按来源启停入口)。
|
||||
func (s *Server) registry(w http.ResponseWriter, r *http.Request) {
|
||||
compiledIDs := make([]string, 0, len(s.CompiledPlugins))
|
||||
for _, cp := range s.CompiledPlugins {
|
||||
compiledIDs = append(compiledIDs, cp.ID)
|
||||
}
|
||||
reg := map[string]any{
|
||||
"tools": s.visibleTools(s.owner(r)),
|
||||
"parsers": ingest.ParsersDescription(),
|
||||
"panels": flattenPanels(s.plugins.List()),
|
||||
"tools": s.visibleTools(s.owner(r)),
|
||||
"parsers": ingest.ParsersDescription(),
|
||||
"panels": flattenPanels(s.plugins.List()),
|
||||
"compiled": compiledIDs,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, reg)
|
||||
}
|
||||
@@ -1709,10 +2054,53 @@ func (s *Server) searchKnowledge(w http.ResponseWriter, r *http.Request) {
|
||||
topK, _ := strconv.Atoi(r.URL.Query().Get("topK"))
|
||||
query := core.RetrievalQuery{ProjectID: r.URL.Query().Get("project"), Text: q, TopK: topK}
|
||||
owner := s.owner(r)
|
||||
if s.projectLifecycle != nil && query.ProjectID != "" {
|
||||
id, err := projectscope.Resolve(r.Context(), s.lifecycleHost(owner), *s.projectLifecycle, query.ProjectID)
|
||||
if err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
query.ProjectID = id
|
||||
}
|
||||
if s.ProjectAuthzV2 != nil {
|
||||
allow := func(d core.KnowledgeDoc) bool {
|
||||
ok, err := s.ProjectAuthzV2(extension.ResourceAuthzQuery{Actor: owner, Action: "search", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source})
|
||||
return err == nil && ok
|
||||
}
|
||||
if rs, ok := s.Retriever.(interface {
|
||||
RetrieveAuthorized(context.Context, core.ID, core.RetrievalQuery, func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error)
|
||||
}); ok {
|
||||
hits, err := rs.RetrieveAuthorized(r.Context(), owner, query, allow)
|
||||
writeJSONOr(w, hits, err)
|
||||
return
|
||||
}
|
||||
hits, err := s.st.SearchKnowledge(owner, query)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
filtered := make([]core.RetrievalHit, 0, len(hits))
|
||||
for _, h := range hits {
|
||||
if allow(h.Doc) {
|
||||
filtered = append(filtered, h)
|
||||
}
|
||||
}
|
||||
shared, err := s.st.SearchKnowledgeSharedAuthorized(owner, query, allow)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
filtered = append(filtered, shared...)
|
||||
if query.TopK > 0 && len(filtered) > query.TopK {
|
||||
filtered = filtered[:query.TopK]
|
||||
}
|
||||
writeJSON(w, http.StatusOK, filtered)
|
||||
return
|
||||
}
|
||||
// EXT-8 余项:Retriever 支持共享版(向量+全文两路追加)时整体走共享版;
|
||||
// 否则维持切片 2 行为(本人检索 + 全文共享追加)。
|
||||
allow := func(docOwner, projectID core.ID) bool {
|
||||
return s.authorizeSharedProject(owner, projectID)
|
||||
return s.authorizeSharedResource(owner, "search", "project", projectID, docOwner, projectID)
|
||||
}
|
||||
if rs, ok := s.Retriever.(interface {
|
||||
RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error)
|
||||
@@ -1777,6 +2165,10 @@ func writeStoreErr(w http.ResponseWriter, err error) {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrBadCredentials) {
|
||||
http.Error(w, "current password incorrect", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, runmgr.ErrConversationBusy) {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package server
|
||||
|
||||
// SSE 长连接会话复验(登录恢复合同 §6.7):密码写入删除会话行后,
|
||||
// 已建立的流在复验周期内终止,不再继续接收私有事件。
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// 打开真实流式服务,等一帧事件后作废会话,断言流在复验周期内关闭。
|
||||
func TestSSESessionInvalidationClosesStream(t *testing.T) {
|
||||
old := sseAuthRecheck
|
||||
sseAuthRecheck = 300 * time.Millisecond
|
||||
t.Cleanup(func() { sseAuthRecheck = old })
|
||||
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
|
||||
hash, err := store.HashPassword("sse-old-pass-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("tia", "", hash, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.ClearMustChangePassword("tia"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tok, err := st.CreateSession("tia")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proj, err := st.CreateProject("tia", "sse-proj")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conv, err := st.CreateConversation("tia", proj.ID, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(h)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", ts.URL+"/api/events?conversationId="+conv.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok})
|
||||
|
||||
type result struct {
|
||||
lastLine string
|
||||
closed bool
|
||||
err error
|
||||
}
|
||||
resCh := make(chan result, 1)
|
||||
go func() {
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
resCh <- result{err: err}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
last := ""
|
||||
for sc.Scan() {
|
||||
if line := sc.Text(); line != "" {
|
||||
last = line
|
||||
}
|
||||
}
|
||||
resCh <- result{lastLine: last, closed: true, err: sc.Err()}
|
||||
}()
|
||||
|
||||
// 等重连提示帧出现,确认流已建立。
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
established := false
|
||||
for !established && time.Now().Before(deadline) {
|
||||
if len(resCh) > 0 {
|
||||
break
|
||||
}
|
||||
// 无阻塞探测:向会话写事件驱动首帧即可省略;此处直接进入作废。
|
||||
established = true
|
||||
}
|
||||
|
||||
// 作废会话(管理员重置语义:删除全部会话 + bump 版本)。
|
||||
newHash, err := store.HashPassword("sse-new-pass-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.AdminResetUserPassword("tia", newHash, "test", "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case r := <-resCh:
|
||||
if r.err != nil {
|
||||
t.Fatalf("stream read error: %v", r.err)
|
||||
}
|
||||
if !strings.Contains(r.lastLine, "session-expired") {
|
||||
t.Fatalf("stream should end with session-expired marker, got %q", r.lastLine)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("stream not closed within recheck window after session invalidation")
|
||||
}
|
||||
}
|
||||
|
||||
// API key 凭证的流:撤销后同样在复验周期内断流(合同 §6.7 补齐)。
|
||||
func TestSSEAPIKeyRevocationClosesStream(t *testing.T) {
|
||||
old := sseAuthRecheck
|
||||
sseAuthRecheck = 300 * time.Millisecond
|
||||
t.Cleanup(func() { sseAuthRecheck = old })
|
||||
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
|
||||
if err := st.EnsureUser("kia", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, err := st.CreateAPIKey("kia", "sse")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proj, err := st.CreateProject("kia", "sse-proj")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conv, err := st.CreateConversation("kia", proj.ID, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(h)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", ts.URL+"/api/events?conversationId="+conv.ID+"&key="+key, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resCh := make(chan string, 1)
|
||||
go func() {
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
resCh <- "err:" + err.Error()
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
last := ""
|
||||
for sc.Scan() {
|
||||
if line := sc.Text(); line != "" {
|
||||
last = line
|
||||
}
|
||||
}
|
||||
resCh <- last
|
||||
}()
|
||||
|
||||
// 等首帧(retry 行)出现后撤销该 Key。
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if _, err := st.RevokeAPIKeyByName("kia", "sse"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case got := <-resCh:
|
||||
if !strings.Contains(got, "session-expired") {
|
||||
t.Fatalf("stream should end with session-expired marker, got %q", got)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("stream not closed within recheck window after key revocation")
|
||||
}
|
||||
}
|
||||
|
||||
// 兑换入口限流:同源连续失败达上限后返回 429(跨实例共担由 store 测试覆盖)。
|
||||
func TestRedeemRateLimited(t *testing.T) {
|
||||
srv, _ := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
for i := 0; i < redeemMaxFails; i++ {
|
||||
req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"alk_not_a_real_key_at_all"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("attempt %d: want 401, got %d", i, rec.Code)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"alk_other"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("want 429 after %d failures, got %d", redeemMaxFails, rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
core "agent-system-core"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaskedProjectMetadata returns a detached credential-safe view without changing
|
||||
// stored values or dotted service/configuration key shapes. The builtin and
|
||||
// plugin secret schema plus the existing secret-leaf fallback cover legacy data.
|
||||
func (s *Service) MaskedProjectMetadata(values map[string]any) map[string]any {
|
||||
if values == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
schema := Schema()
|
||||
if s != nil {
|
||||
schema = append(schema, s.extraSchema...)
|
||||
}
|
||||
secrets := map[string]bool{}
|
||||
for _, d := range schema {
|
||||
if d.Type == core.SettingSecret || d.Secret {
|
||||
secrets[d.Key] = true
|
||||
}
|
||||
}
|
||||
return maskedProjectValue(values, "", secrets).(map[string]any)
|
||||
}
|
||||
|
||||
func maskedProjectValue(value any, path string, secrets map[string]bool) any {
|
||||
leaf := path
|
||||
if i := strings.LastIndexByte(leaf, '.'); i >= 0 {
|
||||
leaf = leaf[i+1:]
|
||||
}
|
||||
if secrets[path] || isSecretKey(leaf) {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
if text, ok := value.(string); ok && text == "" {
|
||||
return text
|
||||
}
|
||||
return MaskPlaceholder
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
if v == nil {
|
||||
return v
|
||||
}
|
||||
out := make(map[string]any, len(v))
|
||||
for key, item := range v {
|
||||
child := key
|
||||
if path != "" {
|
||||
child = path + "." + key
|
||||
}
|
||||
out[key] = maskedProjectValue(item, child, secrets)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
if v == nil {
|
||||
return v
|
||||
}
|
||||
out := make([]any, len(v))
|
||||
for i, item := range v {
|
||||
out[i] = maskedProjectValue(item, path, secrets)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareProjectMetadataWrite applies the same managed-path and mask-placeholder
|
||||
// rules as project settings, while preserving the metadata store's key shapes.
|
||||
// Project metadata lives in projects.settings, not the settings scope table.
|
||||
func (s *Service) PrepareProjectMetadataWrite(values map[string]any) (map[string]any, error) {
|
||||
incoming := dropProjectMaskPlaceholders(values)
|
||||
if err := s.checkManagedWrite(normalizeKeys(incoming)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return incoming, nil
|
||||
}
|
||||
|
||||
// dropProjectMaskPlaceholders prunes a parent only when a previously nonempty
|
||||
// object became empty through mask removal. Explicit empty objects still express
|
||||
// an overwrite and must reach the managed-path guard unchanged.
|
||||
func dropProjectMaskPlaceholders(values map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(values))
|
||||
for key, value := range values {
|
||||
if child, ok := value.(map[string]any); ok {
|
||||
prepared := dropProjectMaskPlaceholders(child)
|
||||
if len(child) > 0 && len(prepared) == 0 {
|
||||
continue
|
||||
}
|
||||
out[key] = prepared
|
||||
continue
|
||||
}
|
||||
if text, ok := value.(string); ok && text == MaskPlaceholder {
|
||||
continue
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) checkManagedWrite(incoming map[string]any) error {
|
||||
if hits := core.TouchesManagedSettings(incoming, s.ManagedSettingPaths()); len(hits) > 0 {
|
||||
return &ErrManagedPath{Paths: hits}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
core "agent-system-core"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMaskedProjectMetadataCopiesSecretViews(t *testing.T) {
|
||||
s := NewService(nil)
|
||||
s.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.credential", Secret: true}})
|
||||
raw := map[string]any{"models.chat.apiKey": "fixture-known", "legacy.token": "fixture-flat", "plugins.private": map[string]any{"credential": "fixture-schema"}, "nested": []any{map[string]any{"api_key": "fixture-array"}}, "opaque.secret": map[string]any{"value": "fixture-opaque"}, "custom.token": []any{"fixture-list"}, "custom.caption": "Allowed", "rdm.projectId": "canonical", "rdm.state": "ready"}
|
||||
before, _ := json.Marshal(raw)
|
||||
view := s.MaskedProjectMetadata(raw)
|
||||
after, _ := json.Marshal(raw)
|
||||
if string(before) != string(after) {
|
||||
t.Fatal("read-only metadata view mutated its source")
|
||||
}
|
||||
encoded, _ := json.Marshal(view)
|
||||
for _, secret := range []string{"fixture-known", "fixture-flat", "fixture-schema", "fixture-array", "fixture-opaque", "fixture-list"} {
|
||||
if bytes.Contains(encoded, []byte(secret)) {
|
||||
t.Fatal("metadata view exposed a fixture secret")
|
||||
}
|
||||
}
|
||||
if view["custom.caption"] != "Allowed" || view["rdm.projectId"] != "canonical" || view["rdm.state"] != "ready" {
|
||||
t.Fatal("nonsecret metadata shape/value changed")
|
||||
}
|
||||
if view["legacy.token"] != MaskPlaceholder || view["opaque.secret"] != MaskPlaceholder || view["custom.token"] != MaskPlaceholder {
|
||||
t.Fatal("secret fallback did not mask complete dotted values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareProjectMetadataWriteUsesManagedGuard(t *testing.T) {
|
||||
s := NewService(nil)
|
||||
s.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.managed", Scope: core.SettingScopeSystem}})
|
||||
for _, raw := range []map[string]any{{"models.chat.apiKey": "fixture"}, {"models": map[string]any{"chat": map[string]any{"apiKey": "fixture"}}}, {"models": "smash"}, {"plugins.private.managed": "override"}} {
|
||||
if _, err := s.PrepareProjectMetadataWrite(raw); err == nil {
|
||||
t.Fatal("managed metadata write accepted")
|
||||
}
|
||||
}
|
||||
raw := map[string]any{"custom.caption": "Allowed", "custom.token": MaskPlaceholder, "models.chat.apiKey": MaskPlaceholder}
|
||||
view, err := s.PrepareProjectMetadataWrite(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(view) != 1 || view["custom.caption"] != "Allowed" {
|
||||
t.Fatal("metadata writes must preserve allowed dotted keys and ignore mask placeholders")
|
||||
}
|
||||
if raw["custom.token"] != MaskPlaceholder {
|
||||
t.Fatal("write preparation mutated its input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareProjectMetadataWritePrunesOnlyMaskedParents(t *testing.T) {
|
||||
s := NewService(nil)
|
||||
for _, raw := range []map[string]any{
|
||||
{"models": map[string]any{"chat": map[string]any{"apiKey": MaskPlaceholder}}, "display": map[string]any{"caption": "Updated"}},
|
||||
{"models.chat": map[string]any{"apiKey": MaskPlaceholder}, "display.caption": "Updated"},
|
||||
{"models": map[string]any{"chat.apiKey": MaskPlaceholder}, "display.caption": "Updated"},
|
||||
} {
|
||||
before, _ := json.Marshal(raw)
|
||||
got, err := s.PrepareProjectMetadataWrite(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("unchanged masked credential rejected legal metadata edit: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatal("mask-only parent remained in prepared metadata")
|
||||
}
|
||||
after, _ := json.Marshal(raw)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("mask preparation changed its input")
|
||||
}
|
||||
}
|
||||
for _, raw := range []map[string]any{
|
||||
{"models": map[string]any{}},
|
||||
{"models": map[string]any{"chat": map[string]any{}}},
|
||||
{"models.chat": map[string]any{}},
|
||||
{"models": map[string]any{"chat": map[string]any{"apiKey": MaskPlaceholder, "model": "Real overwrite"}}},
|
||||
{"models.chat": map[string]any{"apiKey": MaskPlaceholder}, "models": map[string]any{"chat": map[string]any{}}},
|
||||
} {
|
||||
if _, err := s.PrepareProjectMetadataWrite(raw); err == nil {
|
||||
t.Fatal("explicit empty or real managed overwrite accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,24 @@ import (
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// Scope 层级键:user 与 project:<id> 落库;conversation 经会话画像选择实现。
|
||||
// Scope 层级键:user 与 project:<id> 落库;system 是管理员全局层(owner 固定
|
||||
// OwnerSystemRow);conversation 经会话画像选择实现。
|
||||
const (
|
||||
ScopeUser = "user"
|
||||
ScopeProjectPrefix = "project:"
|
||||
ScopeSystem = "system"
|
||||
// OwnerSystemRow 系统层落库行的 owner。空串不是任何账号(账号名非空),
|
||||
// 租户 API 无法读写该行。
|
||||
OwnerSystemRow = ""
|
||||
)
|
||||
|
||||
// ErrManagedPath 表示写入负载触碰系统受管设置项;user/project 层拒绝整笔写入。
|
||||
type ErrManagedPath struct{ Paths []string }
|
||||
|
||||
func (e *ErrManagedPath) Error() string {
|
||||
return "系统受管设置项仅管理员可配置: " + strings.Join(e.Paths, ", ")
|
||||
}
|
||||
|
||||
// SystemDefaults 是系统级默认(代码内置,settings.system.json 语义)。
|
||||
func SystemDefaults() map[string]any {
|
||||
return map[string]any{
|
||||
@@ -77,30 +89,30 @@ func SystemDefaults() map[string]any {
|
||||
// Schema 返回设置描述清单(设置页渲染依据;插件设置项由 pluginhost 追加)。
|
||||
func Schema() []core.SettingDesc {
|
||||
return []core.SettingDesc{
|
||||
{Key: "models.chat.baseUrl", Group: "models", Label: "主模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "OpenAI 兼容端点,如 https://api.openai.com/v1"},
|
||||
{Key: "models.chat.apiKey", Group: "models", Label: "主模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot},
|
||||
{Key: "models.chat.model", Group: "models", Label: "主模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun},
|
||||
{Key: "models.chat.fallbacks", Group: "models", Label: "主模型回退链", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "逗号分隔的模型名。只在尚未输出时,前一个模型返回 5xx 才改用下一个。空表示不回退"},
|
||||
{Key: "models.chat.maxOutputTokens", Group: "models", Label: "主模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"},
|
||||
{Key: "models.chat.contextWindow", Group: "models", Label: "主模型上下文窗口(token)", Type: core.SettingNumber, Default: 128000, Effect: core.EffectNewRun, Description: "模型真实窗口的 token 数。运行内清理旧工具结果的阈值由此换算(此前按 32000 字符硬编码,长材料任务第一请求即触发清理且被清内容找不回)"},
|
||||
{Key: "models.chat.outputReserve", Group: "models", Label: "窗口输出预留(token)", Type: core.SettingNumber, Default: 16384, Effect: core.EffectNewRun, Description: "清理阈值里为模型输出预留的 token 数"},
|
||||
{Key: "models.chat.contextBuffer", Group: "models", Label: "窗口余量(token)", Type: core.SettingNumber, Default: 4096, Effect: core.EffectNewRun, Description: "避免贴着上限才清理的余量"},
|
||||
{Key: "models.lightweight.baseUrl", Group: "models", Label: "轻量模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "标题生成/澄清等轻任务;空则回落主模型"},
|
||||
{Key: "models.lightweight.apiKey", Group: "models", Label: "轻量模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot},
|
||||
{Key: "models.lightweight.model", Group: "models", Label: "轻量模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun},
|
||||
{Key: "models.lightweight.maxOutputTokens", Group: "models", Label: "轻量模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"},
|
||||
{Key: "models.embedding.baseUrl", Group: "models", Label: "Embedding Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "RAG/记忆检索用(向量管线远期启用)"},
|
||||
{Key: "models.embedding.apiKey", Group: "models", Label: "Embedding API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot},
|
||||
{Key: "models.embedding.model", Group: "models", Label: "Embedding 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot},
|
||||
{Key: "models.rerank.baseUrl", Group: "models", Label: "Reranker Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot},
|
||||
{Key: "models.rerank.apiKey", Group: "models", Label: "Reranker API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot},
|
||||
{Key: "models.rerank.model", Group: "models", Label: "Reranker 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot},
|
||||
{Key: "runtime.mock", Group: "runtime", Label: "演示模式(mock runner)", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "不调用真实模型,按脚本回放事件流"},
|
||||
{Key: "models.chat.baseUrl", Group: "models", Label: "主模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "OpenAI 兼容端点,如 https://api.openai.com/v1"},
|
||||
{Key: "models.chat.apiKey", Group: "models", Label: "主模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
||||
{Key: "models.chat.model", Group: "models", Label: "主模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Scope: core.SettingScopeSystem},
|
||||
{Key: "models.chat.fallbacks", Group: "models", Label: "主模型回退链", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "逗号分隔的模型名。只在尚未输出时,前一个模型返回 5xx 才改用下一个。空表示不回退"},
|
||||
{Key: "models.chat.maxOutputTokens", Group: "models", Label: "主模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"},
|
||||
{Key: "models.chat.contextWindow", Group: "models", Label: "主模型上下文窗口(token)", Type: core.SettingNumber, Default: 128000, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "模型真实窗口的 token 数。运行内清理旧工具结果的阈值由此换算(此前按 32000 字符硬编码,长材料任务第一请求即触发清理且被清内容找不回)"},
|
||||
{Key: "models.chat.outputReserve", Group: "models", Label: "窗口输出预留(token)", Type: core.SettingNumber, Default: 16384, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "清理阈值里为模型输出预留的 token 数"},
|
||||
{Key: "models.chat.contextBuffer", Group: "models", Label: "窗口余量(token)", Type: core.SettingNumber, Default: 4096, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "避免贴着上限才清理的余量"},
|
||||
{Key: "models.lightweight.baseUrl", Group: "models", Label: "轻量模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "标题生成/澄清等轻任务;空则回落主模型"},
|
||||
{Key: "models.lightweight.apiKey", Group: "models", Label: "轻量模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
||||
{Key: "models.lightweight.model", Group: "models", Label: "轻量模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Scope: core.SettingScopeSystem},
|
||||
{Key: "models.lightweight.maxOutputTokens", Group: "models", Label: "轻量模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"},
|
||||
{Key: "models.embedding.baseUrl", Group: "models", Label: "Embedding Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "RAG/记忆检索用(向量管线远期启用)"},
|
||||
{Key: "models.embedding.apiKey", Group: "models", Label: "Embedding API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
||||
{Key: "models.embedding.model", Group: "models", Label: "Embedding 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
||||
{Key: "models.rerank.baseUrl", Group: "models", Label: "Reranker Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
||||
{Key: "models.rerank.apiKey", Group: "models", Label: "Reranker API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
||||
{Key: "models.rerank.model", Group: "models", Label: "Reranker 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
||||
{Key: "runtime.mock", Group: "runtime", Label: "演示模式(mock runner)", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "不调用真实模型,按脚本回放事件流"},
|
||||
{Key: "runtime.squad", Group: "runtime", Label: "小队编排(Agent-Squad)", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "启用 Agent-Squad 引擎编排(#squad 指令与 squad_orchestrate 工具)"},
|
||||
{Key: "runtime.entry", Group: "runtime", Label: "对话入口", Type: core.SettingEnum, Default: "direct", Options: []string{"direct", "squad"}, Effect: core.EffectNewRun, Description: "direct 走现有对话循环。squad 由小队先分诊:简单任务单步完成,复杂任务由 leader 编排。灰度期默认 direct"},
|
||||
{Key: "runtime.parallelTools", Group: "runtime", Label: "并行工具执行", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "同一回合并行执行互不依赖的工具调用(检索、读文件、delegate_task 等);卡片流、计划、执行原语等顺序敏感工具仍互斥串行"},
|
||||
{Key: "runtime.rateLimitPerMin", Group: "runtime", Label: "租户限流(次/分)", Type: core.SettingNumber, Default: 0, Effect: core.EffectHot, Description: "0 = 不限;多用户建议 300–1200"},
|
||||
{Key: "runtime.redisAddr", Group: "runtime", Label: "Redis 地址(限流共享)", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "原生 Redis(如 127.0.0.1:6379);空 = 进程内限流(单实例)"},
|
||||
{Key: "runtime.rateLimitPerMin", Group: "runtime", Label: "租户限流(次/分)", Type: core.SettingNumber, Default: 0, Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "0 = 不限;多用户建议 300–1200"},
|
||||
{Key: "runtime.redisAddr", Group: "runtime", Label: "Redis 地址(限流共享)", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "原生 Redis(如 127.0.0.1:6379);空 = 进程内限流(单实例)"},
|
||||
{Key: "runtime.verify", Group: "runtime", Label: "终轮结果检查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "终轮确定性检查与独立验证"},
|
||||
{Key: "runtime.llmRetries", Group: "runtime", Label: "模型重试次数", Type: core.SettingNumber, Default: 2, Effect: core.EffectNewRun, Description: "直连模型在还没有任何输出时,遇到超时、限流或服务端错误的额外重试次数。0 = 不重试。已经开始输出后不再重试"},
|
||||
{Key: "runtime.codeOrchestration", Group: "runtime", Label: "代码编排", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许模型用 run_tool_script 写脚本批量调用工具。默认关闭"},
|
||||
@@ -112,8 +124,8 @@ func Schema() []core.SettingDesc {
|
||||
{Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型重写一段最终回复。默认关闭(裁定 4:转录权威后工作模型已看到完整证据,重写只见过 160 字摘要、实测会虚构章节与承诺未做之事);异常收尾时另有兜底"},
|
||||
{Key: "runtime.intentSemantic", Group: "runtime", Label: "意图语义裁决(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则歧义时用 embedding 语义相似度裁决。需已配置 embedding 端点。默认关闭(行为与 I1 一致)"},
|
||||
{Key: "runtime.intentGenerator", Group: "runtime", Label: "意图命令生成(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则与语义都未命中时用轻量模型生成意图命令(幻觉 fail-closed 回退)。默认关闭(行为与 I1 一致)"},
|
||||
{Key: "telemetry.otlpEndpoint", Group: "telemetry", Label: "OTLP 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "OTel HTTP JSON 上报端点(如 http://localhost:4318)。空 = 不导出(默认);环境变量 OTEL_EXPORTER_OTLP_ENDPOINT 作为后备"},
|
||||
{Key: "telemetry.serviceName", Group: "telemetry", Label: "遥测 service.name", Type: core.SettingString, Default: "agent-system-backend", Effect: core.EffectRestart, Description: "OTLP resource 的 service.name"},
|
||||
{Key: "telemetry.otlpEndpoint", Group: "telemetry", Label: "OTLP 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "OTel HTTP JSON 上报端点(如 http://localhost:4318)。空 = 不导出(默认);环境变量 OTEL_EXPORTER_OTLP_ENDPOINT 作为后备"},
|
||||
{Key: "telemetry.serviceName", Group: "telemetry", Label: "遥测 service.name", Type: core.SettingString, Default: "agent-system-backend", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "OTLP resource 的 service.name"},
|
||||
{Key: "context.maxHistoryMessages", Group: "context", Label: "历史窗口条数", Type: core.SettingNumber, Default: 40, Effect: core.EffectNewRun},
|
||||
{Key: "context.maxToolResultChars", Group: "context", Label: "工具结果上限(字符)", Type: core.SettingNumber, Default: 20000, Effect: core.EffectNewRun},
|
||||
{Key: "context.userInstructions", Group: "context", Label: "个人指令", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "每次对话附在上下文末尾,不改系统前缀"},
|
||||
@@ -124,8 +136,8 @@ func Schema() []core.SettingDesc {
|
||||
{Key: "retrieval.topK", Group: "retrieval", Label: "检索命中数", Type: core.SettingNumber, Default: 5, Effect: core.EffectHot},
|
||||
{Key: "ui.theme", Group: "ui", Label: "主题", Type: core.SettingEnum, Default: "dark", Options: []string{"dark", "light"}, Effect: core.EffectHot},
|
||||
{Key: "ui.cardflow", Group: "ui", Label: "长结果进右侧卡片", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "开启后报告/纪要/方案等产物型长内容经 submit_card 进收件箱,对话只留结论摘要与锚点(默认开,用户 2026-10-11 裁定)"},
|
||||
{Key: "tools.tavily.apiKeys", Group: "tools", Label: "Tavily API Keys(池)", Type: core.SettingSecret, Default: "", Effect: core.EffectRestart, Description: "逗号分隔;配置后注册 tavily_search/web_search 工具(429/401/403 自动轮换下一把)"},
|
||||
{Key: "tools.tavily.endpoint", Group: "tools", Label: "Tavily 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "空 = 官方 https://api.tavily.com/search;可指向自建代理"},
|
||||
{Key: "tools.tavily.apiKeys", Group: "tools", Label: "Tavily API Keys(池)", Type: core.SettingSecret, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "逗号分隔;配置后注册 tavily_search/web_search 工具(429/401/403 自动轮换下一把)"},
|
||||
{Key: "tools.tavily.endpoint", Group: "tools", Label: "Tavily 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "空 = 官方 https://api.tavily.com/search;可指向自建代理"},
|
||||
{Key: "tools.mcp.overrides", Group: "tools", Label: "MCP 项目覆盖", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "JSON 对象。键是服务器名,值可含 enabled 与 exposure(direct 或 deferred)"},
|
||||
{Key: "deliverables.style", Group: "deliverables", Label: "交付物默认样式", Type: core.SettingEnum, Default: "modern", Options: []string{"modern", "formal", "minimal"}, Effect: core.EffectNewRun, Description: "modern 现代专业 / formal 公文 / minimal 极简。本轮指令、上传模板或参考文档优先于这项"},
|
||||
{Key: "deliverables.accentColor", Group: "deliverables", Label: "交付物强调色", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "6 位十六进制,如 1B3A4B。空 = 使用样式自带主色"},
|
||||
@@ -139,6 +151,9 @@ func Schema() []core.SettingDesc {
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
system map[string]any // 启动注入的系统层覆盖(如数据目录),位于 defaults 与 user 之间
|
||||
// extraSchema 是插件设置声明(组合根经 SetExtraSchema 注入);
|
||||
// 受管路径计算同时覆盖内置 schema 与插件声明。
|
||||
extraSchema []core.SettingDesc
|
||||
}
|
||||
|
||||
func NewService(st *store.Store) *Service { return &Service{st: st} }
|
||||
@@ -146,30 +161,62 @@ func NewService(st *store.Store) *Service { return &Service{st: st} }
|
||||
// SetSystemOverride 注入启动期系统层覆盖(组合根调用;不落库)。
|
||||
func (s *Service) SetSystemOverride(values map[string]any) { s.system = values }
|
||||
|
||||
// Effective 计算覆盖链结果:defaults → system(启动注入) → user(租户) → project。
|
||||
// SetExtraSchema 注入插件设置声明(受管标记由插件按需声明)。
|
||||
func (s *Service) SetExtraSchema(descs []core.SettingDesc) { s.extraSchema = descs }
|
||||
|
||||
// ManagedSettingPaths 返回全部受管键路径(内置 schema + 插件声明)。
|
||||
func (s *Service) ManagedSettingPaths() []string {
|
||||
paths := core.ManagedPaths(Schema())
|
||||
if s != nil && len(s.extraSchema) > 0 {
|
||||
paths = append(paths, core.ManagedPaths(s.extraSchema)...)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// Effective 计算覆盖链结果:defaults → system(启动注入) → system(管理员落库)
|
||||
// → user(租户) → project。受管项在 user/project 层的存量覆盖被剥除,不再生效。
|
||||
// owner 是租户身份(认证中间件注入;未启用认证 = store.OwnerDefault)。
|
||||
func (s *Service) Effective(owner, projectID string) core.EffectiveSettings {
|
||||
eff := core.MergeSettings(SystemDefaults(), map[string]any{})
|
||||
if s.system != nil {
|
||||
eff = core.MergeSettings(eff, s.system)
|
||||
}
|
||||
if sysdb, err := s.st.GetSettingsScope(OwnerSystemRow, ScopeSystem); err == nil {
|
||||
eff = core.MergeSettings(eff, sysdb)
|
||||
}
|
||||
managed := s.ManagedSettingPaths()
|
||||
if user, err := s.st.GetSettingsScope(owner, ScopeUser); err == nil {
|
||||
eff = core.MergeSettings(eff, user)
|
||||
eff = core.MergeSettings(eff, core.StripManagedSettings(normalizeKeys(user), managed))
|
||||
}
|
||||
if projectID != "" {
|
||||
if proj, err := s.st.GetSettingsScope(owner, ScopeProjectPrefix+projectID); err == nil {
|
||||
eff = core.MergeSettings(eff, proj)
|
||||
eff = core.MergeSettings(eff, core.StripManagedSettings(normalizeKeys(proj), managed))
|
||||
}
|
||||
}
|
||||
return eff
|
||||
}
|
||||
|
||||
func (s *Service) UserScope(owner string) (map[string]any, error) {
|
||||
return s.st.GetSettingsScope(owner, ScopeUser)
|
||||
return s.readScopeStripped(owner, ScopeUser)
|
||||
}
|
||||
|
||||
func (s *Service) ProjectScope(owner, projectID string) (map[string]any, error) {
|
||||
return s.st.GetSettingsScope(owner, ScopeProjectPrefix+projectID)
|
||||
return s.readScopeStripped(owner, ScopeProjectPrefix+projectID)
|
||||
}
|
||||
|
||||
// readScopeStripped 读库并剥除受管路径:受管项对租户层不再生效,也不在设置页
|
||||
// 回显为"当前值"(存量值保留在库中作为迁移依据,P8 处置)。
|
||||
func (s *Service) readScopeStripped(owner, scope string) (map[string]any, error) {
|
||||
m, err := s.st.GetSettingsScope(owner, scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return core.StripManagedSettings(normalizeKeys(m), s.ManagedSettingPaths()), nil
|
||||
}
|
||||
|
||||
// SystemScope 返回管理员落库的系统层(未脱敏;HTTP 读面自行 Masked)。
|
||||
func (s *Service) SystemScope() (map[string]any, error) {
|
||||
return s.st.GetSettingsScope(OwnerSystemRow, ScopeSystem)
|
||||
}
|
||||
|
||||
func (s *Service) MergeUser(owner string, values map[string]any) error {
|
||||
@@ -180,10 +227,21 @@ func (s *Service) MergeProject(owner, projectID string, values map[string]any) e
|
||||
return s.mergeScope(owner, ScopeProjectPrefix+projectID, values)
|
||||
}
|
||||
|
||||
// MergeSystem 写管理员系统层(仅系统管理 API 调用;不做受管拦截——系统层
|
||||
// 就是受管项的权威写入面;非受管项写入形成全局默认覆盖,用户层仍可再覆盖)。
|
||||
func (s *Service) MergeSystem(values map[string]any) error {
|
||||
return s.mergeScope(OwnerSystemRow, ScopeSystem, values)
|
||||
}
|
||||
|
||||
// mergeScope 先把库里和本次写入的点路径键展开成嵌套,再深合并。
|
||||
// 掩码占位符视为未修改,不覆盖已存密钥。
|
||||
// 租户层(user/project)触碰受管路径时整笔拒绝。掩码占位符视为未修改,不覆盖已存密钥。
|
||||
func (s *Service) mergeScope(owner, scope string, values map[string]any) error {
|
||||
incoming := prepareWrite(values)
|
||||
if scope != ScopeSystem {
|
||||
if err := s.checkManagedWrite(incoming); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.st.UpdateSettingsScope(owner, scope, func(cur map[string]any) (map[string]any, error) {
|
||||
return core.MergeSettings(normalizeKeys(cur), incoming), nil
|
||||
}); err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -180,8 +181,9 @@ func TestMergeUserDottedKeyMaskAndNormalizeStored(t *testing.T) {
|
||||
if eff.GetString("runtime.entry", "") != "squad" {
|
||||
t.Fatalf("entry = %q", eff.GetString("runtime.entry", ""))
|
||||
}
|
||||
if eff.GetString("tools.tavily.apiKeys", "") != "nested" {
|
||||
t.Fatalf("nested key lost: %q", eff.GetString("tools.tavily.apiKeys", ""))
|
||||
// P2 受管:tools.tavily.apiKeys 属系统受管项,user 层存量覆盖不再生效。
|
||||
if got := eff.GetString("tools.tavily.apiKeys", ""); got != "" {
|
||||
t.Fatalf("managed key leaked from user scope: %q", got)
|
||||
}
|
||||
if eff.GetNumber("context.maxHistoryMessages", 0) != 30 {
|
||||
t.Fatalf("history = %v", eff.GetNumber("context.maxHistoryMessages", 0))
|
||||
@@ -204,14 +206,27 @@ func TestMergeUserDottedKeyMaskAndNormalizeStored(t *testing.T) {
|
||||
if _, ok := raw["runtime.entry"]; ok {
|
||||
t.Fatalf("dotted key persisted: %#v", raw)
|
||||
}
|
||||
if err := svc.MergeUser("local", map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "sk-real"}}}); err != nil {
|
||||
// P2 受管:user 层写受管路径(嵌套与点路径形式)都整笔拒绝。
|
||||
var mp *ErrManagedPath
|
||||
if err := svc.MergeUser("local", map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "sk-real"}}}); !errors.As(err, &mp) {
|
||||
t.Fatalf("nested managed write not rejected: %v", err)
|
||||
}
|
||||
if err := svc.MergeUser("local", map[string]any{"models.chat.apiKey": "sk-real"}); !errors.As(err, &mp) {
|
||||
t.Fatalf("dotted managed write not rejected: %v", err)
|
||||
}
|
||||
if err := svc.MergeUser("local", map[string]any{"models": "smash"}); !errors.As(err, &mp) {
|
||||
t.Fatalf("ancestor scalar overwrite not rejected: %v", err)
|
||||
}
|
||||
// 受管项只能经系统层写入并生效。
|
||||
if err := svc.MergeSystem(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "sk-sys"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.MergeUser("local", map[string]any{"models.chat.apiKey": MaskPlaceholder}); err != nil {
|
||||
t.Fatal(err)
|
||||
if got := svc.Effective("local", "").GetString("models.chat.apiKey", ""); got != "sk-sys" {
|
||||
t.Fatalf("system-layer key not effective: %q", got)
|
||||
}
|
||||
if got := svc.Effective("local", "").GetString("models.chat.apiKey", ""); got != "sk-real" {
|
||||
t.Fatalf("mask writeback overwrote key: %q", got)
|
||||
// 读面剥离:UserScope 不回显受管项的存量值。
|
||||
if raw2, _ := svc.UserScope("local"); raw2["models"] != nil {
|
||||
t.Fatalf("managed path exposed in user read scope: %#v", raw2["models"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,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.
@@ -0,0 +1,93 @@
|
||||
Copyright 2024 The Boldonse Project Authors (https://github.com/googlefonts/boldonse)
|
||||
|
||||
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 2022 The Bricolage Grotesque Project Authors (https://github.com/ateliertriay/bricolage)
|
||||
|
||||
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.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2018 The Crimson Pro Project Authors (https://github.com/Fonthausen/CrimsonPro)
|
||||
|
||||
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.
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The DM Mono Project Authors (https://www.github.com/googlefonts/dm-mono)
|
||||
|
||||
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.
@@ -0,0 +1,94 @@
|
||||
Copyright (c) 2011 by LatinoType Limitada (luciano@latinotype.com),
|
||||
with Reserved Font Names "Erica One"
|
||||
|
||||
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 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git)
|
||||
|
||||
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.
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Gloock Project Authors (https://github.com/duartp/gloock)
|
||||
|
||||
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 © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
|
||||
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
|
||||
|
||||
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.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright (c) 2011, Santiago Orozco (hi@typemade.mx), with Reserved Font Name "Italiana".
|
||||
|
||||
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 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
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.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2019 The Jura Project Authors (https://github.com/ossobuffo/jura)
|
||||
|
||||
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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user