Add a single boot-config pipeline (file/env/CLI) with env profiles and explain #5

Closed
weichen wants to merge 1 commits from feature/boot-config into main
12 changed files with 1326 additions and 24 deletions
+53
View File
@@ -3,3 +3,56 @@
对话系统后端:Go 模块化单体,默认构建为单二进制(go:embed 内嵌前端与嵌入式 SQLite 存储)。组合 Agent-Native / Agent-Squad / Agent-Intent 生态,依赖 agent-system-core 契约。
契约与架构见 Agent-System-Documents 设计方案与架构文档;开发规范见 development-guidelines(Dev 仓 `development/guidelines`)。本仓为 Agent-System 多仓组合的组成部分。
## 启动配置(配置文件 / 环境变量 / 命令行)
启动参数走**单一解析管道**(`internal/config`,规范:`standards/configuration.md`),优先级从低到高:
```
内置默认 → 显式配置文件(--config,环境段式)→ 环境变量(AGENT_SYSTEM_*)→ 显式 CLI
```
只有实际传入的 CLI 参数参与覆盖(显式传默认值同样参与);删除 override 恢复继承。配置文件必须显式给出,环境段必须显式选择,不从 cwd 或分支名推断。任何未知键或类型错误都会在启动时定位报错(文件#环境段.键),不静默失效。
### 配置文件:环境段式(推荐)
一个文件承载 dev/test/prod 等多套环境,`--env`(或 `AGENT_SYSTEM_ENV`)选择段;也可以是平面单段文件(此时不能与 `--env` 同用):
```bash
agent-system config init --out agent-system.json # 生成含 dev/test/prod 的模板(0600)
agent-system config show --config agent-system.json --env prod # 只读诊断:逐键来源 + 脱敏
agent-system serve --config agent-system.json --env dev # 启动 dev 环境
AGENT_SYSTEM_CONFIG=/etc/agent-system.json AGENT_SYSTEM_ENV=prod agent-system serve
```
示例见 `configs/agent-system.example.json`。段内键:
| 键 | 说明 |
|---|---|
| `addr` | 监听地址(默认 127.0.0.1:8787) |
| `data` | 工作区根目录(SQLite 与工件默认父目录;默认 ./data) |
| `plugins` | 插件目录(默认 ./plugins) |
| `artifacts` | 工件对象目录(空 = `<data>/artifacts`;PG 档多实例可指向共享卷) |
| `dsn` | PostgreSQL 档 DSN(空 = 嵌入式 SQLite 档) |
| `auth` | API key 认证:`auto`(PG 档开、嵌入式关)\| `on` \| `off` |
| `mock` | 演示模式(布尔;系统层注入 `runtime.mock`,**不落库**) |
| `settings` | 设置树覆盖(models/runtime/context/retrieval/ui 键值须符合设置 schema,未知键报错) |
`settings` 子树经系统层注入(位于内置默认与租户 user 层之间):模型密钥可经文件下发而不落库、设置页掩码显示;租户仍可在 user/project 层覆盖可调项。`GET /api/settings` 返回的 `system` 字段即该层(脱敏)。
### 环境变量(登记清单)
| 变量 | 对应键 |
|---|---|
| `AGENT_SYSTEM_CONFIG` / `AGENT_SYSTEM_ENV` | 配置文件路径 / 环境段名(空串视为未设置) |
| `AGENT_SYSTEM_ADDR` / `AGENT_SYSTEM_DATA` / `AGENT_SYSTEM_PLUGINS` / `AGENT_SYSTEM_ARTIFACTS` | 对应启动键(不允许空值) |
| `AGENT_SYSTEM_DSN` | 空串合法(显式回落嵌入式档) |
| `AGENT_SYSTEM_AUTH` / `AGENT_SYSTEM_MOCK` | auto\|on\|off / ParseBool 词法(true/false/1/0) |
### 实例隔离
独立环境段默认隔离可写目录与监听入口;`config show` 会对各段 `data` 目录重叠(相同或嵌套)给出告警。同一文件多段共用 `plugins` 目录是合法的(声明资产只读加载)。
### 兼容性说明
旧行为 `--mock` 会把 `runtime.mock=true` 写入 user 设置(粘性残留);现改为启动注入、不落库。曾用 `--mock` 启动过的库,user 层可能残留 `runtime.mock=true`,需在设置页改回。
+114 -18
View File
@@ -2,6 +2,8 @@
// 部署约束(设计 4.1 原则 8):非容器化;拷贝即运行。
// 嵌入式档默认(SQLite + 本地对象目录,零外部服务);--dsn 切换 PostgreSQL 档
//(多用户/多实例,认证默认开启,apikey 子命令管理密钥)。
// 启动配置走单一解析管道(internal/config):内置默认 → 配置文件(--config,环境段式)
// → 环境变量(AGENT_SYSTEM_*)→ 显式 CLI;`config show` 输出来源与脱敏诊断。
package main
import (
@@ -11,6 +13,7 @@ import (
"os"
"agent-system-backend/internal/app"
"agent-system-backend/internal/config"
)
var version = "0.1.0-dev"
@@ -23,40 +26,120 @@ func main() {
return
case "serve":
os.Args = append([]string{os.Args[0]}, os.Args[2:]...)
case "config":
os.Exit(runConfig(os.Args[2:]))
case "apikey":
os.Exit(runAPIKey(os.Args[2:]))
}
}
fs := flag.NewFlagSet("serve", flag.ExitOnError)
addr := fs.String("addr", "127.0.0.1:8787", "监听地址")
data := fs.String("data", "./data", "数据目录(SQLite 与工件对象目录)")
pluginsDir := fs.String("plugins", "./plugins", "插件目录(声明资产热加载)")
mock := fs.Bool("mock", false, "演示模式(等同 runtime.mock=true,不调用模型)")
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式 SQLite 档)")
artifacts := fs.String("artifacts", "", "工件对象目录(默认 <data>/artifacts;PG 档多实例时指向共享卷)")
auth := fs.String("auth", "auto", "API key 认证:auto(PG 档开、嵌入式关)| on | off")
fs.Parse(os.Args[1:])
f := newServeFlagSet("serve")
f.fs.Parse(os.Args[1:])
res, err := config.Resolve(config.Input{
ConfigPath: *f.configPath, EnvName: *f.envName,
Flags: config.FlagsFromFlagSet(f.fs),
})
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
os.Exit(1)
}
for _, w := range res.Warnings {
fmt.Fprintln(os.Stderr, "agent-system: warning:", w)
}
if err := app.Run(app.Options{
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, ForceMock: *mock,
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth,
Addr: res.Addr, DataDir: res.DataDir, PluginsDir: res.PluginsDir, ForceMock: res.Mock,
StorageDSN: res.StorageDSN, ArtifactsDir: res.ArtifactsDir, AuthMode: res.AuthMode,
SettingsOverlay: res.SettingsOverlay, ConfigSummary: res.Summary(),
}); err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
os.Exit(1)
}
}
// runAPIKey:密钥管理子命令(create/list/revoke)。
// serveFlags 定义 serve 与 config show 共用的启动参数面(flag 名即配置键)。
type serveFlags struct {
fs *flag.FlagSet
configPath *string
envName *string
}
func newServeFlagSet(name string) *serveFlags {
fs := flag.NewFlagSet(name, flag.ExitOnError)
f := &serveFlags{fs: fs}
f.configPath = fs.String("config", "", "配置文件路径(环境段式或平面 JSON;空 = 不用文件层)")
f.envName = fs.String("env", "", "配置文件环境段名(dev/test/prod…;文件含段时必填)")
fs.String("addr", "127.0.0.1:8787", "监听地址")
fs.String("data", "./data", "数据目录(SQLite 与工件对象目录)")
fs.String("plugins", "./plugins", "插件目录(声明资产热加载)")
fs.Bool("mock", false, "演示模式(系统层注入 runtime.mock,不落库)")
fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式 SQLite 档)")
fs.String("artifacts", "", "工件对象目录(默认 <data>/artifacts;PG 档多实例时指向共享卷)")
fs.String("auth", "auto", "API key 认证:auto(PG 档开、嵌入式关)| on | off")
return f
}
// runConfig:只读诊断与模板生成(不创建数据库、不改业务目录;init 显式写目标文件)。
func runConfig(args []string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "用法:agent-system config <show|init> [--config f --env prod | --out f]")
return 2
}
switch args[0] {
case "show":
f := newServeFlagSet("config show")
jsonOut := f.fs.Bool("json", false, "机器可读输出(同样脱敏)")
f.fs.Parse(args[1:])
res, err := config.Resolve(config.Input{
ConfigPath: *f.configPath, EnvName: *f.envName,
Flags: config.FlagsFromFlagSet(f.fs),
})
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system config:", err)
return 1
}
if *jsonOut {
out, err := res.ExplainJSON()
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system config:", err)
return 1
}
fmt.Println(string(out))
return 0
}
fmt.Print(res.ExplainText())
return 0
case "init":
fs := flag.NewFlagSet("config init", flag.ExitOnError)
out := fs.String("out", "./agent-system.json", "目标文件(拒绝覆盖既有文件)")
fs.Parse(args[1:])
if err := config.InitFile(*out); err != nil {
fmt.Fprintln(os.Stderr, "agent-system config:", err)
return 1
}
fmt.Printf("OK: %s(dev/test/prod 环境段;CHANGE_ME 为占位,权限 0600)\n", *out)
fmt.Printf("下一步:agent-system serve --config %s --env dev # 或 AGENT_SYSTEM_CONFIG=%s AGENT_SYSTEM_ENV=dev\n", *out, *out)
return 0
default:
fmt.Fprintln(os.Stderr, "config: 未知子命令", args[0])
return 2
}
}
// runAPIKey:密钥管理子命令(create/list/revoke);存储档定位与 serve 同管道
//(--config/--env 解析文件层,--dsn/--data 显式值优先)。
func runAPIKey(args []string) int {
// 子命令在前、flag 在后:先剥出子命令再解析(Go flag 遇位置参数即停)。
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "用法:agent-system apikey <create|list|revoke> [--owner x --name y]")
fmt.Fprintln(os.Stderr, "用法:agent-system apikey <create|list|revoke> [--config f --env prod | --dsn d --data dir] --owner x [--name y]")
return 2
}
sub := args[0]
fs := flag.NewFlagSet("apikey "+sub, flag.ExitOnError)
data := fs.String("data", "./data", "数据目录")
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)")
configPath := fs.String("config", "", "配置文件路径(环境段式或平面)")
envName := fs.String("env", "", "配置文件环境段名")
data := fs.String("data", "", "数据目录(未给 = 按解析管道取)")
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(未给 = 按解析管道取)")
owner := fs.String("owner", "", "租户名(owner)")
name := fs.String("name", "default", "密钥名称")
fs.Parse(args[1:])
@@ -64,9 +147,22 @@ func runAPIKey(args []string) int {
fmt.Fprintln(os.Stderr, "apikey: --owner 必填")
return 2
}
// 显式 flag 参与 CLI 层;未给(空)不覆盖文件/环境层。
flags := map[string]string{}
if *data != "" {
flags["data"] = *data
}
if *dsn != "" {
flags["dsn"] = *dsn
}
res, err := config.Resolve(config.Input{ConfigPath: *configPath, EnvName: *envName, Flags: flags})
if err != nil {
fmt.Fprintln(os.Stderr, "apikey:", err)
return 1
}
switch sub {
case "create":
key, err := app.CreateAPIKey(*dsn, *data, *owner, *name)
key, err := app.CreateAPIKey(res.StorageDSN, res.DataDir, *owner, *name)
if err != nil {
fmt.Fprintln(os.Stderr, "apikey create:", err)
return 1
@@ -75,7 +171,7 @@ func runAPIKey(args []string) int {
fmt.Println(key)
return 0
case "list":
keys, err := app.ListAPIKeys(*dsn, *data, *owner)
keys, err := app.ListAPIKeys(res.StorageDSN, res.DataDir, *owner)
if err != nil {
fmt.Fprintln(os.Stderr, "apikey list:", err)
return 1
@@ -84,7 +180,7 @@ func runAPIKey(args []string) int {
fmt.Println(string(out))
return 0
case "revoke":
ok, err := app.RevokeAPIKey(*dsn, *data, *owner, *name)
ok, err := app.RevokeAPIKey(res.StorageDSN, res.DataDir, *owner, *name)
if err != nil {
fmt.Fprintln(os.Stderr, "apikey revoke:", err)
return 1
+35
View File
@@ -0,0 +1,35 @@
{
"dev": {
"addr": "127.0.0.1:8787",
"data": "./data-dev",
"plugins": "./plugins",
"mock": true,
"settings": {
"models": {
"chat": {
"baseUrl": "https://api.openai.com/v1",
"apiKey": "CHANGE_ME",
"model": "gpt-4o-mini"
}
}
}
},
"test": {
"addr": "127.0.0.1:8788",
"data": "./data-test",
"plugins": "./plugins"
},
"prod": {
"addr": "127.0.0.1:8787",
"data": "/var/lib/agent-system",
"plugins": "./plugins",
"dsn": "postgres://agent:CHANGE_ME@127.0.0.1:5432/agent_system?sslmode=disable",
"auth": "auto",
"settings": {
"runtime": {
"rateLimitPerMin": 300,
"redisAddr": "127.0.0.1:6379"
}
}
}
}
+20 -6
View File
@@ -39,6 +39,10 @@ type Options struct {
StorageDSN string // 空 = 嵌入式 SQLite 档;非空 = PostgreSQL 档(多用户/多实例)
ArtifactsDir string // PG 档工件对象目录(空 = <DataDir>/artifacts)
AuthMode string // auto(默认:PG 档开、嵌入式关)| on | off
// 以下由启动配置管道(internal/config)解析注入:
SettingsOverlay map[string]any // 配置文件 settings 子树 → 系统层(defaults 与 user 之间);不落库
ConfigSummary string // 启动配置摘要(日志用,如 "file=agent-system.json env=prod")
}
// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。
@@ -66,16 +70,26 @@ func Run(opt Options) error {
return fmt.Errorf("builtin tools: %w", err)
}
setSvc := settings.NewService(st)
setSvc.SetSystemOverride(map[string]any{"storage": map[string]any{"dataDir": opt.DataDir}})
// 系统层覆盖(启动注入,位于 defaults 与 user 之间;不落库):目录实际值、
// mock 与配置文件 settings 子树。租户仍可在 user/project 层覆盖可调项。
override := map[string]any{
"storage": map[string]any{"dataDir": opt.DataDir},
"plugins": map[string]any{"dir": opt.PluginsDir},
}
if opt.ForceMock {
override["runtime"] = map[string]any{"mock": true}
}
if len(opt.SettingsOverlay) > 0 {
override = core.MergeSettings(override, opt.SettingsOverlay)
}
setSvc.SetSystemOverride(override)
if opt.ConfigSummary != "" {
log.Printf("config: %s", opt.ConfigSummary)
}
profSvc := profiles.New(st)
if err := profSvc.EnsureDefault(); err != nil {
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 {
return err
}
}
convRunner := &runtime.LLMRunner{}
subRunner := &runtime.SubLLMRunner{}
+425
View File
@@ -0,0 +1,425 @@
// Package config 实现启动配置的单一解析管道(standards/configuration.md):
// 内置默认 → 显式配置文件(环境段)→ 已登记进程环境变量 → 显式 CLI。
// 统一解析为快照(Resolved)后由组合根消费;其余包不得绕过本包自行读取环境变量。
// 配置文件必须显式给出(--config / AGENT_SYSTEM_CONFIG),不从 cwd 或父目录猜测;
// 环境段(dev/test/prod…)也须显式选择(--env / AGENT_SYSTEM_ENV),不从分支名或目录推断。
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"agent-system-backend/internal/settings"
)
// Boot 是启动档:决定进程装配(监听地址、工作区目录、存储档、认证),不被租户设置覆盖。
type Boot struct {
Addr string // HTTP/SSE 监听地址
DataDir string // 工作区根目录(SQLite/工件默认父目录)
PluginsDir string // 插件目录
ArtifactsDir string // 工件对象目录;空 = <DataDir>/artifacts
StorageDSN string // PostgreSQL 档 DSN;空 = 嵌入式 SQLite 档
AuthMode string // auto(PG 档开、嵌入式关)| on | off
Mock bool // 演示模式(系统层注入 runtime.mock,不落库)
}
// Resolved 是解析快照:启动档生效值、来源标注、设置树覆盖与实例隔离告警。
type Resolved struct {
Boot
SettingsOverlay map[string]any // 配置文件 settings 子树(深合并注入系统层,用户/项目层仍可覆盖)
Path string // 实际使用的配置文件路径;空 = 未使用文件层
Env string // 选中的环境段名;空 = 平面文件或无文件层
Sources map[string]string
Warnings []string
}
// Input 汇集除内置默认外的三层输入;Flags 只含实际传入的 CLI 参数(flag.Visit 采集)。
type Input struct {
ConfigPath string // --config;空时查 AGENT_SYSTEM_CONFIG
EnvName string // --env;空时查 AGENT_SYSTEM_ENV
Environ []string // 进程环境(nil = os.Environ();测试注入确定性环境)
Flags map[string]string // 显式 CLI:键为启动键(addr/data/...),值为字符串形式
}
// 已登记进程环境变量(配置面仅此清单;新增须同步 README 与 explain 输出)。
const (
EnvConfig = "AGENT_SYSTEM_CONFIG"
EnvEnv = "AGENT_SYSTEM_ENV"
)
var envVars = map[string]string{
"addr": "AGENT_SYSTEM_ADDR",
"data": "AGENT_SYSTEM_DATA",
"plugins": "AGENT_SYSTEM_PLUGINS",
"artifacts": "AGENT_SYSTEM_ARTIFACTS",
"dsn": "AGENT_SYSTEM_DSN",
"auth": "AGENT_SYSTEM_AUTH",
"mock": "AGENT_SYSTEM_MOCK",
}
var bootKeys = []string{"addr", "data", "plugins", "artifacts", "dsn", "auth", "mock"}
// Defaults 返回内置默认(与 CLI usage 文案保持一致)。
func Defaults() Boot {
return Boot{Addr: "127.0.0.1:8787", DataDir: "./data", PluginsDir: "./plugins", AuthMode: "auto"}
}
// Resolve 按优先级合并四层来源并校验;任何错误定位到具体键与来源。
func Resolve(in Input) (*Resolved, error) {
env := in.Environ
if env == nil {
env = os.Environ()
}
envMap := parseEnviron(env)
res := &Resolved{Boot: Defaults(), Sources: map[string]string{}}
for k := range envVars {
res.Sources[k] = "default"
}
path := in.ConfigPath
if path == "" {
path = envMap[EnvConfig] // 空串视为未设置(路径型变量不允许空值)
}
envName := in.EnvName
if envName == "" {
envName = envMap[EnvEnv]
}
if path != "" {
if err := loadFile(res, path, envName); err != nil {
return nil, err
}
res.Path = path
res.Env = envName
} else if envName != "" {
return nil, fmt.Errorf("config: --env %q 需要配置文件(--config 或 %s)", envName, EnvConfig)
}
if err := applyEnv(res, envMap); err != nil {
return nil, err
}
if err := applyFlags(res, in.Flags); err != nil {
return nil, err
}
if err := validateBoot(res.Boot); err != nil {
return nil, err
}
return res, nil
}
// Summary 供启动日志与 config show 顶栏:文件与环境段,或注明未使用文件层。
func (r *Resolved) Summary() string {
if r.Path == "" {
return "内置默认(未提供配置文件)"
}
if r.Env == "" {
return "file=" + r.Path
}
return "file=" + r.Path + " env=" + r.Env
}
// ---- 文件层 ----
// loadFile 读取并校验配置文件(环境段式或平面式),应用选中段的值并做实例隔离核对。
func loadFile(res *Resolved, path, envName string) error {
body, err := parseFile(path)
if err != nil {
return err
}
if body.flat != nil {
if envName != "" {
return fmt.Errorf("config: %s 是平面配置(无环境段),不能与 --env %q 同用", path, envName)
}
applySection(res, *body.flat)
return nil
}
names := make([]string, 0, len(body.sections))
for n := range body.sections {
names = append(names, n)
}
sort.Strings(names)
if envName == "" {
return fmt.Errorf("config: %s 含环境段 [%s],须以 --env 或 %s 显式选择(不从目录或分支名推断)",
path, strings.Join(names, " "), EnvEnv)
}
sec, ok := body.sections[envName]
if !ok {
return fmt.Errorf("config: %s 无环境段 %q(可用:%s)", path, envName, strings.Join(names, " "))
}
applySection(res, sec)
warnInstanceIsolation(path, body.sections, res)
return nil
}
// fileBody 是配置文件的两种合法形状:环境段集合(推荐,承载 dev/test/prod)或平面单段。
type fileBody struct {
sections map[string]section
flat *section
}
// section 是一个环境段(或平面文件)的已校验内容;source 用于来源标注与错误定位。
type section struct {
values map[string]any
source string
}
// parseFile 解析并整体校验文件:任一段的未知键/类型错误都会使文件不可用(防拼写静默失效)。
func parseFile(path string) (*fileBody, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("config: 读取 %s: %w", path, err)
}
var root map[string]any
if err := json.Unmarshal(data, &root); err != nil {
return nil, fmt.Errorf("config: %s 不是合法 JSON: %w", path, err)
}
if len(root) == 0 {
return nil, fmt.Errorf("config: %s 内容为空", path)
}
// 形状判定:已知启动键或 settings 出现在顶层 → 平面文件;否则视为环境段集合。
for k := range root {
if k == "settings" || isBootKey(k) {
return &fileBody{flat: &(section{values: root, source: "file:" + path})}, nil
}
}
sections := map[string]section{}
for name, body := range root {
m, ok := body.(map[string]any)
if !ok {
return nil, fmt.Errorf("config: %s#%s: 环境段必须是对象", path, name)
}
sec, err := parseSection(m, fmt.Sprintf("file:%s#%s", path, name))
if err != nil {
return nil, err
}
sections[name] = sec
}
return &fileBody{sections: sections}, nil
}
// parseSection 校验段内键与类型:启动键 + settings 子树(子树按设置 schema 校验)。
func parseSection(m map[string]any, source string) (section, error) {
for k, v := range m {
switch {
case isBootKey(k):
if err := checkBootJSONType(k, v, source); err != nil {
return section{}, err
}
case k == "settings":
sm, ok := v.(map[string]any)
if !ok {
return section{}, fmt.Errorf("config: %s: settings 必须是对象", source)
}
if err := settings.ValidateTreeOverlay(sm); err != nil {
return section{}, fmt.Errorf("config: %s: %v", source, err)
}
default:
return section{}, fmt.Errorf("config: %s: 未知键 %q(启动键:%s;或 settings 子树)",
source, k, strings.Join(bootKeys, "/"))
}
}
return section{values: m, source: source}, nil
}
func checkBootJSONType(k string, v any, source string) error {
if k == "mock" {
if _, ok := v.(bool); !ok {
return fmt.Errorf("config: %s: %s 须为布尔值", source, k)
}
return nil
}
if _, ok := v.(string); !ok {
return fmt.Errorf("config: %s: %s 须为字符串", source, k)
}
return nil
}
// applySection 把段中出现的键写入快照(JSON 显式出现即参与覆盖,含空串值)。
func applySection(res *Resolved, sec section) {
for k, v := range sec.values {
if k == "settings" {
if res.SettingsOverlay == nil {
res.SettingsOverlay = map[string]any{}
}
res.SettingsOverlay = mergeMaps(res.SettingsOverlay, v.(map[string]any))
continue
}
if k == "mock" {
res.Mock = v.(bool)
} else {
res.setField(k, v.(string))
}
res.Sources[k] = sec.source
}
}
// warnInstanceIsolation 核对各环境段 data 目录(字面路径取绝对+清理后)是否重叠:
// 独立实例默认隔离可写目录(standards/configuration.md);重叠仅告警不阻断,
// 由部署者确认是否刻意共享。
func warnInstanceIsolation(path string, sections map[string]section, res *Resolved) {
type dirOf struct{ name, dir string }
var list []dirOf
for name, sec := range sections {
if d, ok := sec.values["data"].(string); ok && d != "" {
list = append(list, dirOf{name, absClean(d)})
}
}
for i := 0; i < len(list); i++ {
for j := i + 1; j < len(list); j++ {
a, b := list[i], list[j]
if a.dir == b.dir || containsDir(a.dir, b.dir) || containsDir(b.dir, a.dir) {
res.Warnings = append(res.Warnings, fmt.Sprintf(
"环境段 %s 与 %s 的 data 目录重叠(%s 与 %s)——独立实例默认隔离可写目录,请确认是否刻意",
a.name, b.name, a.dir, b.dir))
}
}
}
}
func containsDir(parent, child string) bool {
return strings.HasPrefix(child, parent+string(filepath.Separator))
}
func absClean(p string) string {
abs, err := filepath.Abs(p)
if err != nil {
return filepath.Clean(p)
}
return filepath.Clean(abs)
}
// ---- 环境层与 CLI 层 ----
// applyEnv 应用已登记环境变量:变量出现即参与覆盖(含空串语义,见 setFrom)。
func applyEnv(res *Resolved, envMap map[string]string) error {
keys := make([]string, 0, len(envVars))
for k := range envVars {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
v, ok := envMap[envVars[k]]
if !ok {
continue
}
if err := setFrom(res, k, v, "env:"+envVars[k]); err != nil {
return err
}
}
return nil
}
// applyFlags 应用显式传入的 CLI(仅实际出现的参数参与;显式传默认值同样参与)。
func applyFlags(res *Resolved, flags map[string]string) error {
for _, k := range bootKeys {
v, ok := flags[k]
if !ok {
continue
}
if err := setFrom(res, k, v, "flag:--"+k); err != nil {
return err
}
}
return nil
}
// setFrom 写入单个启动键并校验该来源的值(空值/非法值就地报错,定位到来源)。
// dsn 与 artifacts 允许空串(空 dsn = 显式嵌入式档;空 artifacts = 取 <data>/artifacts);
// 其余键空串无效。mock 接受 strconv.ParseBool 词法。
func setFrom(res *Resolved, k, v, source string) error {
switch k {
case "mock":
b, err := strconv.ParseBool(v)
if err != nil {
return fmt.Errorf("config: %s: 布尔值须为 true/false(得到 %q)", source, v)
}
res.Mock = b
case "dsn", "artifacts":
res.setField(k, v)
default:
if v == "" {
return fmt.Errorf("config: %s: 值为空(该键不允许空值)", source)
}
if k == "auth" && v != "auto" && v != "on" && v != "off" {
return fmt.Errorf("config: %s: auth 须为 auto|on|off(得到 %q)", source, v)
}
res.setField(k, v)
}
res.Sources[k] = source
return nil
}
func validateBoot(b Boot) error {
if b.Addr == "" {
return fmt.Errorf("config: addr 为空")
}
if b.DataDir == "" {
return fmt.Errorf("config: data 为空")
}
if b.PluginsDir == "" {
return fmt.Errorf("config: plugins 为空")
}
if b.AuthMode != "auto" && b.AuthMode != "on" && b.AuthMode != "off" {
return fmt.Errorf("config: auth 须为 auto|on|off(得到 %q)", b.AuthMode)
}
return nil
}
func (b *Boot) setField(k, v string) {
switch k {
case "addr":
b.Addr = v
case "data":
b.DataDir = v
case "plugins":
b.PluginsDir = v
case "artifacts":
b.ArtifactsDir = v
case "dsn":
b.StorageDSN = v
case "auth":
b.AuthMode = v
}
}
func isBootKey(k string) bool {
for _, b := range bootKeys {
if k == b {
return true
}
}
return false
}
func parseEnviron(env []string) map[string]string {
out := map[string]string{}
for _, e := range env {
if i := strings.IndexByte(e, '='); i > 0 {
out[e[:i]] = e[i+1:]
}
}
return out
}
// mergeMaps 深合并(与 core.MergeSettings 同语义;本地包装避免引入契约包耦合)。
func mergeMaps(base, over map[string]any) map[string]any {
out := make(map[string]any, len(base)+len(over))
for k, v := range base {
out[k] = v
}
for k, v := range over {
if bm, ok := out[k].(map[string]any); ok {
if vm, ok2 := v.(map[string]any); ok2 {
out[k] = mergeMaps(bm, vm)
continue
}
}
out[k] = v
}
return out
}
+335
View File
@@ -0,0 +1,335 @@
package config
import (
"flag"
"os"
"path/filepath"
"strings"
"testing"
)
func writeFile(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "agent-system.json")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestResolveDefaults(t *testing.T) {
res, err := Resolve(Input{Environ: []string{}})
if err != nil {
t.Fatal(err)
}
d := Defaults()
if res.Addr != d.Addr || res.DataDir != d.DataDir || res.PluginsDir != d.PluginsDir ||
res.StorageDSN != "" || res.AuthMode != "auto" || res.Mock {
t.Fatalf("默认快照异常: %+v", res.Boot)
}
for k, s := range res.Sources {
if s != "default" {
t.Fatalf("键 %s 来源应为 default,得到 %s", k, s)
}
}
if res.Path != "" || res.Env != "" || len(res.Warnings) != 0 {
t.Fatalf("未提供文件层时不应有文件/环境段/告警: %+v", res)
}
}
func TestPrecedenceFlagOverEnvOverFile(t *testing.T) {
path := writeFile(t, `{"dev": {"addr": "127.0.0.1:5000"}, "prod": {"addr": "127.0.0.1:6000"}}`)
// 文件层
res, err := Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{}})
if err != nil {
t.Fatal(err)
}
if res.Addr != "127.0.0.1:6000" || res.Sources["addr"] != "file:"+path+"#prod" {
t.Fatalf("文件层未生效: addr=%s source=%s", res.Addr, res.Sources["addr"])
}
// 环境层覆盖文件层
res, err = Resolve(Input{ConfigPath: path, EnvName: "prod",
Environ: []string{"AGENT_SYSTEM_ADDR=127.0.0.1:300"}})
if err != nil {
t.Fatal(err)
}
if res.Addr != "127.0.0.1:300" || res.Sources["addr"] != "env:AGENT_SYSTEM_ADDR" {
t.Fatalf("环境层未覆盖文件层: addr=%s source=%s", res.Addr, res.Sources["addr"])
}
// CLI 层覆盖环境层
res, err = Resolve(Input{ConfigPath: path, EnvName: "prod",
Environ: []string{"AGENT_SYSTEM_ADDR=127.0.0.1:300"}, Flags: map[string]string{"addr": "127.0.0.1:9"}})
if err != nil {
t.Fatal(err)
}
if res.Addr != "127.0.0.1:9" || res.Sources["addr"] != "flag:--addr" {
t.Fatalf("CLI 层未覆盖环境层: addr=%s source=%s", res.Addr, res.Sources["addr"])
}
// 删除环境与 CLI 覆盖 → 回落到文件层
res, err = Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{}})
if err != nil {
t.Fatal(err)
}
if res.Addr != "127.0.0.1:6000" {
t.Fatalf("删除 override 后未恢复继承: addr=%s", res.Addr)
}
}
func TestUnsetFlagDoesNotParticipate(t *testing.T) {
// 未显式传入的 CLI 不参与覆盖(仅 flag.Visit 采集显式项)。
path := writeFile(t, `{"prod": {"addr": "127.0.0.1:6000"}}`)
res, err := Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{},
Flags: map[string]string{"mock": "true"}}) // 只传了 mock,未传 addr
if err != nil {
t.Fatal(err)
}
if res.Addr != "127.0.0.1:6000" || !res.Mock {
t.Fatalf("快照异常: addr=%s mock=%v", res.Addr, res.Mock)
}
if res.Sources["addr"] != "file:"+path+"#prod" || res.Sources["mock"] != "flag:--mock" {
t.Fatalf("来源标注异常: %+v", res.Sources)
}
}
func TestExplicitDefaultFlagOverridesFile(t *testing.T) {
// 显式传入且值恰为默认值:仍参与覆盖(configuration.md:实际传入即参与)。
path := writeFile(t, `{"prod": {"addr": "127.0.0.1:6000"}}`)
res, err := Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{},
Flags: map[string]string{"addr": "127.0.0.1:8787"}})
if err != nil {
t.Fatal(err)
}
if res.Addr != "127.0.0.1:8787" || res.Sources["addr"] != "flag:--addr" {
t.Fatalf("显式默认值未参与覆盖: addr=%s source=%s", res.Addr, res.Sources["addr"])
}
}
func TestSectionsSelection(t *testing.T) {
path := writeFile(t, `{"dev": {"data": "./d1", "mock": true}, "prod": {"data": "./d2", "dsn": "postgres://u:p@h/db"}}`)
res, err := Resolve(Input{ConfigPath: path, EnvName: "dev", Environ: []string{}})
if err != nil {
t.Fatal(err)
}
if res.DataDir != "./d1" || !res.Mock || res.StorageDSN != "" {
t.Fatalf("dev 段未生效: %+v", res.Boot)
}
// 段名经环境变量选择
res, err = Resolve(Input{ConfigPath: path, Environ: []string{"AGENT_SYSTEM_ENV=prod"}})
if err != nil {
t.Fatal(err)
}
if res.DataDir != "./d2" || res.StorageDSN == "" || res.Mock {
t.Fatalf("prod 段未生效: %+v", res.Boot)
}
// 文件路径经环境变量发现
res, err = Resolve(Input{Environ: []string{"AGENT_SYSTEM_CONFIG=" + path, "AGENT_SYSTEM_ENV=prod"}})
if err != nil {
t.Fatal(err)
}
if res.Path != path || res.DataDir != "./d2" {
t.Fatalf("AGENT_SYSTEM_CONFIG 发现异常: %+v", res)
}
}
func TestSectionsRequireExplicitEnv(t *testing.T) {
path := writeFile(t, `{"dev": {"data": "./d1"}, "prod": {"data": "./d2"}}`)
_, err := Resolve(Input{ConfigPath: path, Environ: []string{}})
if err == nil || !strings.Contains(err.Error(), "dev prod") {
t.Fatalf("环境段文件未显式选段应报错并列出可用段: %v", err)
}
_, err = Resolve(Input{ConfigPath: path, EnvName: "staging", Environ: []string{}})
if err == nil || !strings.Contains(err.Error(), `环境段 "staging"`) {
t.Fatalf("未知段应定位报错: %v", err)
}
}
func TestFlatFileRejectsEnvFlag(t *testing.T) {
path := writeFile(t, `{"addr": "127.0.0.1:6000", "settings": {"retrieval": {"topK": 8}}}`)
res, err := Resolve(Input{ConfigPath: path, Environ: []string{}})
if err != nil {
t.Fatal(err)
}
if res.Addr != "127.0.0.1:6000" || res.Env != "" {
t.Fatalf("平面文件解析异常: %+v", res)
}
if res.SettingsOverlay == nil || int(res.SettingsOverlay["retrieval"].(map[string]any)["topK"].(float64)) != 8 {
t.Fatalf("settings 子树未进入覆盖: %+v", res.SettingsOverlay)
}
_, err = Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{}})
if err == nil || !strings.Contains(err.Error(), "平面配置") {
t.Fatalf("平面文件 + --env 应报错: %v", err)
}
}
func TestEnvWithoutConfigFile(t *testing.T) {
_, err := Resolve(Input{EnvName: "prod", Environ: []string{}})
if err == nil || !strings.Contains(err.Error(), "--env") {
t.Fatalf("无配置文件却给 --env 应报错: %v", err)
}
}
func TestUnknownBootKeyLocated(t *testing.T) {
path := writeFile(t, `{"prod": {"adrr": "127.0.0.1:6000"}}`)
_, err := Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{}})
if err == nil || !strings.Contains(err.Error(), "adrr") || !strings.Contains(err.Error(), "#prod") {
t.Fatalf("未知键应定位到文件与段: %v", err)
}
// 未选中段的未知键同样使文件不可用(文件是一个整体,防拼写静默潜伏)
_, err = Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{}})
if err == nil {
t.Fatal("未知键应报错")
}
}
func TestBootJSONTypeErrors(t *testing.T) {
path := writeFile(t, `{"prod": {"mock": "yes"}}`)
_, err := Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{}})
if err == nil || !strings.Contains(err.Error(), "mock 须为布尔值") {
t.Fatalf("mock 类型错误应定位: %v", err)
}
path = writeFile(t, `{"prod": {"addr": 8080}}`)
_, err = Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{}})
if err == nil || !strings.Contains(err.Error(), "addr 须为字符串") {
t.Fatalf("addr 类型错误应定位: %v", err)
}
}
func TestSettingsOverlayValidation(t *testing.T) {
cases := []struct {
json, wantErr string
}{
{`{"prod": {"settings": {"models": {"cht": {"model": "x"}}}}}`, "models.cht"},
{`{"prod": {"settings": {"retrieval": {"topK": "5"}}}}`, "retrieval.topK"},
{`{"prod": {"settings": {"runtime": {"mock": "true"}}}}`, "runtime.mock"},
{`{"prod": {"settings": {"ui": {"theme": "blue"}}}}`, "ui.theme"},
{`{"prod": {"settings": {"retrieval": {"topK": 8}, "ui": {"theme": "light"}}}}`, ""},
}
for _, c := range cases {
path := writeFile(t, c.json)
_, err := Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{}})
if c.wantErr == "" {
if err != nil {
t.Fatalf("合法设置子树不应报错: %v", err)
}
continue
}
if err == nil || !strings.Contains(err.Error(), c.wantErr) {
t.Fatalf("应定位到 %s: %v", c.wantErr, err)
}
}
}
func TestEnvVarValueValidation(t *testing.T) {
// 空串语义:dsn 允许空(显式嵌入式档,覆盖文件层),addr 不允许空
path := writeFile(t, `{"prod": {"dsn": "postgres://u:p@h/db"}}`)
res, err := Resolve(Input{ConfigPath: path, EnvName: "prod",
Environ: []string{"AGENT_SYSTEM_DSN="}})
if err != nil {
t.Fatal(err)
}
if res.StorageDSN != "" || res.Sources["dsn"] != "env:AGENT_SYSTEM_DSN" {
t.Fatalf("空 DSN 应显式回落嵌入式档: %+v", res.Boot)
}
_, err = Resolve(Input{ConfigPath: path, EnvName: "prod",
Environ: []string{"AGENT_SYSTEM_ADDR="}})
if err == nil || !strings.Contains(err.Error(), "env:AGENT_SYSTEM_ADDR") {
t.Fatalf("空 addr 应定位报错: %v", err)
}
_, err = Resolve(Input{Environ: []string{"AGENT_SYSTEM_MOCK=yes"}})
if err == nil || !strings.Contains(err.Error(), "AGENT_SYSTEM_MOCK") {
t.Fatalf("非法布尔应定位报错: %v", err)
}
res, err = Resolve(Input{Environ: []string{"AGENT_SYSTEM_MOCK=1"}})
if err != nil || !res.Mock {
t.Fatalf("ParseBool 词法(1)应接受: %v %+v", err, res.Boot)
}
_, err = Resolve(Input{Environ: []string{"AGENT_SYSTEM_AUTH=maybe"}})
if err == nil || !strings.Contains(err.Error(), "auto|on|off") {
t.Fatalf("auth 非法值应报错: %v", err)
}
}
func TestInstanceIsolationWarning(t *testing.T) {
path := writeFile(t, `{"dev": {"data": "/srv/agent"}, "prod": {"data": "/srv/agent"}}`)
res, err := Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{}})
if err != nil {
t.Fatal(err)
}
if len(res.Warnings) != 1 || !strings.Contains(res.Warnings[0], "dev") || !strings.Contains(res.Warnings[0], "prod") {
t.Fatalf("data 目录重叠应告警: %+v", res.Warnings)
}
// 嵌套同样告警
path = writeFile(t, `{"dev": {"data": "/srv/agent"}, "prod": {"data": "/srv/agent/data-dev"}}`)
res, err = Resolve(Input{ConfigPath: path, EnvName: "dev", Environ: []string{}})
if err != nil {
t.Fatal(err)
}
if len(res.Warnings) != 1 {
t.Fatalf("嵌套 data 目录应告警: %+v", res.Warnings)
}
}
func TestExplainMasksSecrets(t *testing.T) {
path := writeFile(t, `{"prod": {"dsn": "postgres://agent:secret@127.0.0.1:5432/db",
"settings": {"models": {"chat": {"apiKey": "sk-live-123"}}}}}`)
res, err := Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{}})
if err != nil {
t.Fatal(err)
}
text := res.ExplainText()
if strings.Contains(text, "secret@") || strings.Contains(text, "sk-live-123") {
t.Fatalf("explain 泄密:\n%s", text)
}
if !strings.Contains(text, "agent:xxxxx@") {
t.Fatalf("DSN 脱敏异常:\n%s", text)
}
if !strings.Contains(text, "••••••") {
t.Fatalf("设置密钥未掩码:\n%s", text)
}
js, err := res.ExplainJSON()
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(js), "secret@") || strings.Contains(string(js), "sk-live-123") {
t.Fatalf("explain JSON 泄密: %s", js)
}
}
func TestInitAndTemplateResolves(t *testing.T) {
out := filepath.Join(t.TempDir(), "conf", "agent-system.json")
if err := InitFile(out); err != nil {
t.Fatal(err)
}
if err := InitFile(out); err == nil || !strings.Contains(err.Error(), "拒绝覆盖") {
t.Fatalf("重复 init 应拒绝覆盖: %v", err)
}
// 模板各段都能被解析管道接受(自洽性)
for _, env := range []string{"dev", "test", "prod"} {
if _, err := Resolve(Input{ConfigPath: out, EnvName: env, Environ: []string{}}); err != nil {
t.Fatalf("模板段 %s 解析失败: %v", env, err)
}
}
info, err := os.Stat(out)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("init 应写 0600 权限: %v", info.Mode())
}
}
func TestFlagsFromFlagSet(t *testing.T) {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
fs.String("addr", "127.0.0.1:8787", "")
fs.String("data", "./data", "")
fs.Bool("mock", false, "")
fs.String("env", "", "") // 非启动键:不应进入覆盖
if err := fs.Parse([]string{"--addr", "0.0.0.0:80", "--env", "prod"}); err != nil {
t.Fatal(err)
}
got := FlagsFromFlagSet(fs)
want := map[string]string{"addr": "0.0.0.0:80"}
if len(got) != len(want) || got["addr"] != want["addr"] {
t.Fatalf("显式采集异常: %+v", got)
}
}
+188
View File
@@ -0,0 +1,188 @@
// explain 诊断(只读)、示例模板与 init:standards/configuration.md 要求的
// check/explain 能力——展示来源与脱敏状态,不创建数据库、不写业务目录(init 显式要求写盘除外)。
package config
import (
"encoding/json"
"flag"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
core "agent-system-core"
"agent-system-backend/internal/settings"
)
// FlagsFromFlagSet 采集实际传入的 CLI 参数(flag.Visit 只访问显式设置的项);
// 值统一转字符串,mock 由 ParseBool 解析。未传的参数不参与覆盖。
func FlagsFromFlagSet(fs *flag.FlagSet) map[string]string {
out := map[string]string{}
fs.Visit(func(f *flag.Flag) {
if isBootKey(f.Name) {
out[f.Name] = f.Value.String()
}
})
return out
}
// ExplainText 渲染人读诊断:逐键生效值与来源、设置树覆盖(脱敏)、实例隔离告警。
func (r *Resolved) ExplainText() string {
var b strings.Builder
fmt.Fprintf(&b, "agent-system 配置解析(优先级:默认 → 文件 → 环境 → CLI)\n")
fmt.Fprintf(&b, "config: %s\n", r.Summary())
hints := map[string]string{
"dsn": hintDSN(r.StorageDSN),
"artifacts": hintArtifacts(r.ArtifactsDir),
"auth": hintAuth(r.AuthMode),
}
for _, k := range bootKeys {
v := r.fieldString(k)
if h := hints[k]; h != "" {
v = h
}
fmt.Fprintf(&b, " %-10s %-46s ← %s\n", k, v, r.Sources[k])
}
if len(r.SettingsOverlay) > 0 {
enc, _ := json.MarshalIndent(settings.Masked(core.EffectiveSettings(r.SettingsOverlay)), "", " ")
fmt.Fprintf(&b, "settings 覆盖(系统层注入,用户/项目层可覆盖;密钥脱敏):\n %s\n", string(enc))
}
for _, w := range r.Warnings {
fmt.Fprintf(&b, "warning: %s\n", w)
}
return b.String()
}
// ExplainJSON 渲染机器可读诊断(同样脱敏)。
func (r *Resolved) ExplainJSON() ([]byte, error) {
out := struct {
ConfigPath string `json:"configPath,omitempty"`
Env string `json:"env,omitempty"`
Boot map[string]string `json:"boot"`
Sources map[string]string `json:"sources"`
SettingsOverlay map[string]any `json:"settingsOverlay,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}{
ConfigPath: r.Path,
Env: r.Env,
Boot: map[string]string{
"addr": r.Addr, "data": r.DataDir, "plugins": r.PluginsDir,
"artifacts": r.ArtifactsDir, "dsn": maskDSN(r.StorageDSN),
"auth": r.AuthMode, "mock": fmt.Sprintf("%v", r.Mock),
},
Sources: r.Sources,
SettingsOverlay: settings.Masked(core.EffectiveSettings(r.SettingsOverlay)),
Warnings: r.Warnings,
}
if r.SettingsOverlay == nil {
out.SettingsOverlay = nil
}
return json.MarshalIndent(out, "", " ")
}
func (r *Resolved) fieldString(k string) string {
switch k {
case "addr":
return r.Addr
case "data":
return r.DataDir
case "plugins":
return r.PluginsDir
case "artifacts":
return r.ArtifactsDir
case "dsn":
return maskDSN(r.StorageDSN)
case "auth":
return r.AuthMode
case "mock":
return fmt.Sprintf("%v", r.Mock)
}
return ""
}
func hintDSN(dsn string) string {
if dsn == "" {
return "(未设置 = 嵌入式 SQLite 档)"
}
return ""
}
func hintArtifacts(dir string) string {
if dir == "" {
return "(未设置 = <data>/artifacts)"
}
return ""
}
func hintAuth(mode string) string {
if mode == "auto" {
return "auto(PG 档自动开启认证)"
}
return ""
}
// maskDSN 脱敏 DSN 中的密码(URL 形式经 net/url Redacted;libpq key=value 形式掩掉 password 值)。
func maskDSN(dsn string) string {
if dsn == "" {
return ""
}
if u, err := url.Parse(dsn); err == nil && u.Scheme != "" && u.Host != "" {
return u.Redacted()
}
if i := strings.Index(dsn, "password="); i >= 0 {
rest := dsn[i+len("password="):]
end := strings.IndexAny(rest, " \t")
if end < 0 {
end = len(rest)
}
return dsn[:i] + "password=***" + rest[end:]
}
return dsn
}
// Template 返回环境段式示例配置(dev/test/prod 三段,覆盖常用键)。
func Template() []byte {
tpl := map[string]any{
"dev": map[string]any{
"addr": "127.0.0.1:8787", "data": "./data-dev", "plugins": "./plugins", "mock": true,
"settings": map[string]any{
"models": map[string]any{"chat": map[string]any{
"baseUrl": "https://api.openai.com/v1", "apiKey": "CHANGE_ME", "model": "gpt-4o-mini",
}},
},
},
"test": map[string]any{
"addr": "127.0.0.1:8788", "data": "./data-test", "plugins": "./plugins",
},
"prod": map[string]any{
"addr": "127.0.0.1:8787", "data": "/var/lib/agent-system", "plugins": "./plugins",
"dsn": "postgres://agent:CHANGE_ME@127.0.0.1:5432/agent_system?sslmode=disable",
"auth": "auto",
"settings": map[string]any{
"runtime": map[string]any{"rateLimitPerMin": 300, "redisAddr": "127.0.0.1:6379"},
},
},
}
out, _ := json.MarshalIndent(tpl, "", " ")
return append(out, '\n')
}
// InitFile 写出示例配置(拒绝覆盖既有文件;权限 0600,配置可含密钥)。
func InitFile(path string) error {
if path == "" {
return fmt.Errorf("config init: --out 不能为空")
}
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("config init: %s 已存在,拒绝覆盖", path)
}
if dir := filepath.Dir(path); dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("config init: %w", err)
}
}
if err := os.WriteFile(path, Template(), 0o600); err != nil {
return fmt.Errorf("config init: %w", err)
}
return nil
}
+36
View File
@@ -660,3 +660,39 @@ func TestModelRoutingOverrideChain(t *testing.T) {
t.Fatalf("run error should carry profile-routed model name: %q", errMsg)
}
}
// TestSettingsAPIExposesSystemLayer:设置 API 呈现启动注入层(配置文件/CLI/环境
// 经组合根 SetSystemOverride 注入),API 消费方可还原 defaults→system→user→project
// 完整覆盖链;密钥脱敏与 user 层一致。
func TestSettingsAPIExposesSystemLayer(t *testing.T) {
srv, _ := newTestServer(t)
h := srv.Handler()
srv.set.SetSystemOverride(map[string]any{
"storage": map[string]any{"dataDir": "/srv/data"},
"runtime": map[string]any{"mock": true},
"models": map[string]any{"chat": map[string]any{"apiKey": "sk-boot-123", "model": "boot-model"}},
})
code, out := doJSON(t, h, "GET", "/api/settings", nil)
if code != 200 {
t.Fatalf("get settings: %d", code)
}
sys, _ := out["system"].(map[string]any)
if sys == nil {
t.Fatalf("响应缺少 system 层: %v", out)
}
if got := sys["storage"].(map[string]any)["dataDir"]; got != "/srv/data" {
t.Fatalf("system.storage.dataDir: %v", got)
}
if got := sys["models"].(map[string]any)["chat"].(map[string]any)["model"]; got != "boot-model" {
t.Fatalf("system.models.chat.model: %v", got)
}
if got := sys["models"].(map[string]any)["chat"].(map[string]any)["apiKey"]; got != "••••••" {
t.Fatalf("system 层密钥应脱敏: %v", got)
}
// 生效链:system 层在 user 层之下——user 未设置时生效值来自 system。
eff := srv.set.Effective(store.OwnerDefault, "")
if !eff.GetBool("runtime.mock", false) || eff.GetString("models.chat.model", "") != "boot-model" {
t.Fatal("system 层应参与 Effective 计算")
}
}
+1
View File
@@ -582,6 +582,7 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"schema": schema,
"defaults": settings.Masked(core.EffectiveSettings(settings.SystemDefaults())),
"system": maskMap(s.set.SystemLayer()), // 启动注入层(配置文件/CLI/环境;密钥脱敏)
"user": maskMap(mustUser(s.set, owner)),
"project": maskMap(mustScope(s.set, owner, projectID)),
})
+76
View File
@@ -0,0 +1,76 @@
// 设置树覆盖校验:配置文件 settings 子树按本包 schema(4.9 权威)校验,
// 未知键与类型错误定位到路径,防拼写静默失效(standards/configuration.md)。
package settings
import (
"fmt"
"sort"
core "agent-system-core"
)
// ValidateTreeOverlay 校验设置树覆盖:叶路径须在 Schema 中且类型/枚举匹配。
func ValidateTreeOverlay(tree map[string]any) error {
schema := map[string]core.SettingDesc{}
for _, d := range Schema() {
schema[d.Key] = d
}
return validateOverlay("", tree, schema)
}
func validateOverlay(prefix string, m map[string]any, schema map[string]core.SettingDesc) error {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
path := k
if prefix != "" {
path = prefix + "." + k
}
if sub, ok := m[k].(map[string]any); ok {
if err := validateOverlay(path, sub, schema); err != nil {
return err
}
continue
}
d, known := schema[path]
if !known {
return fmt.Errorf("settings.%s: 未知设置项(不在 schema;防拼写错误)", path)
}
if err := checkValueType(d, m[k]); err != nil {
return fmt.Errorf("settings.%s: %v", path, err)
}
}
return nil
}
func checkValueType(d core.SettingDesc, v any) error {
switch d.Type {
case core.SettingString, core.SettingSecret:
if _, ok := v.(string); !ok {
return fmt.Errorf("须为字符串(得到 %T)", v)
}
case core.SettingBool:
if _, ok := v.(bool); !ok {
return fmt.Errorf("须为布尔值(得到 %T)", v)
}
case core.SettingNumber:
if _, ok := v.(float64); !ok {
return fmt.Errorf("须为数字(得到 %T)", v)
}
case core.SettingEnum:
s, ok := v.(string)
if !ok {
return fmt.Errorf("须为字符串(得到 %T)", v)
}
for _, o := range d.Options {
if s == o {
return nil
}
}
return fmt.Errorf("须为 %v 之一(得到 %q)", d.Options, s)
}
return nil
}
+34
View File
@@ -0,0 +1,34 @@
package settings
import (
"strings"
"testing"
)
func TestValidateTreeOverlay(t *testing.T) {
ok := map[string]any{
"models": map[string]any{"chat": map[string]any{"baseUrl": "https://x", "apiKey": "k", "model": "m"}},
"runtime": map[string]any{"mock": true, "rateLimitPerMin": 300.0},
"retrieval": map[string]any{"topK": 8.0},
"ui": map[string]any{"theme": "light"},
}
if err := ValidateTreeOverlay(ok); err != nil {
t.Fatalf("合法覆盖不应报错: %v", err)
}
bad := []struct {
tree map[string]any
wantPath string
}{
{map[string]any{"models": map[string]any{"chat": map[string]any{"basUrl": "x"}}}, "models.chat.basUrl"},
{map[string]any{"runtime": map[string]any{"redisAddr": 6379.0}}, "runtime.redisAddr"},
{map[string]any{"retrieval": map[string]any{"topK": "8"}}, "retrieval.topK"},
{map[string]any{"runtime": map[string]any{"mock": "true"}}, "runtime.mock"},
{map[string]any{"ui": map[string]any{"theme": "blue"}}, "ui.theme"},
}
for _, c := range bad {
err := ValidateTreeOverlay(c.tree)
if err == nil || !strings.Contains(err.Error(), c.wantPath) {
t.Fatalf("应定位到 %s: %v", c.wantPath, err)
}
}
}
+9
View File
@@ -83,6 +83,15 @@ func NewService(st *store.Store) *Service { return &Service{st: st} }
// SetSystemOverride 注入启动期系统层覆盖(组合根调用;不落库)。
func (s *Service) SetSystemOverride(values map[string]any) { s.system = values }
// SystemLayer 返回系统层覆盖(启动注入快照;nil 安全返回空 map)。
// 设置 API 据此呈现完整覆盖链:defaults → system → user → project。
func (s *Service) SystemLayer() map[string]any {
if s.system == nil {
return map[string]any{}
}
return s.system
}
// Effective 计算覆盖链结果:defaults → system(启动注入) → user(租户) → project。
// owner 是租户身份(认证中间件注入;未启用认证 = store.OwnerDefault)。
func (s *Service) Effective(owner, projectID string) core.EffectiveSettings {