From 8270acf5e085be2177cc211ff31dd70891d3b538 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 04:22:40 +0000 Subject: [PATCH] Add a single boot-config pipeline (file/env/CLI) with env profiles and explain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: 启动参数原先只有 CLI flags 与库内设置,无法以配置文件按 dev/test/prod 环境切换工作区目录、存储档、模型密钥等;需要配置文件/环境变量/命令行三层 来源统一解析并支持多环境启动。 Evidence: go test ./... 全绿(config/settings 新增单测 + server 系统层 e2e); 真实二进制冒烟:config init/show(逐键来源标注、DSN 与 apiKey 脱敏、env+flag 覆盖优先级)、serve --config --env dev(启动日志与 /api/settings system 层、 user 层无 mock 残留)、configs/agent-system.example.json 三段均可解析。 Impact: serve/apikey 新增 --config/--env 与 AGENT_SYSTEM_* 环境变量;新增 config show|init 子命令(只读诊断与模板);--mock 改为启动注入、不再写入 user 设置(旧库残留处理见 README 兼容性说明);GET /api/settings 附加 system 字段(向后兼容)。 Refs: development-guidelines standards/configuration.md(单一解析管道/只读 explain/实例隔离);设计方案 4.9 设置分层。 --- README.md | 53 ++++ cmd/agent-system/main.go | 132 ++++++++-- configs/agent-system.example.json | 35 +++ internal/app/app.go | 26 +- internal/config/config.go | 425 ++++++++++++++++++++++++++++++ internal/config/config_test.go | 335 +++++++++++++++++++++++ internal/config/view.go | 188 +++++++++++++ internal/server/e2e_test.go | 36 +++ internal/server/server.go | 1 + internal/settings/overlay.go | 76 ++++++ internal/settings/overlay_test.go | 34 +++ internal/settings/settings.go | 9 + 12 files changed, 1326 insertions(+), 24 deletions(-) create mode 100644 configs/agent-system.example.json create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/view.go create mode 100644 internal/settings/overlay.go create mode 100644 internal/settings/overlay_test.go diff --git a/README.md b/README.md index ec7501c..5787069 100644 --- a/README.md +++ b/README.md @@ -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` | 工件对象目录(空 = `/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`,需在设置页改回。 diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index 5ae5417..f8ab2b7 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -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", "", "工件对象目录(默认 /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", "", "工件对象目录(默认 /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 [--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 [--owner x --name y]") + fmt.Fprintln(os.Stderr, "用法:agent-system apikey [--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 diff --git a/configs/agent-system.example.json b/configs/agent-system.example.json new file mode 100644 index 0000000..e12878c --- /dev/null +++ b/configs/agent-system.example.json @@ -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" + } + } + } +} diff --git a/internal/app/app.go b/internal/app/app.go index 4d2d246..05f4f70 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -39,6 +39,10 @@ type Options struct { StorageDSN string // 空 = 嵌入式 SQLite 档;非空 = PostgreSQL 档(多用户/多实例) ArtifactsDir string // PG 档工件对象目录(空 = /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{} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..14d5120 --- /dev/null +++ b/internal/config/config.go @@ -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 // 工件对象目录;空 = /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 = 取 /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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..2416580 --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/config/view.go b/internal/config/view.go new file mode 100644 index 0000000..798fa99 --- /dev/null +++ b/internal/config/view.go @@ -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 "(未设置 = /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 +} diff --git a/internal/server/e2e_test.go b/internal/server/e2e_test.go index a6b9723..8697870 100644 --- a/internal/server/e2e_test.go +++ b/internal/server/e2e_test.go @@ -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 计算") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 01f2eb0..df577e0 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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)), }) diff --git a/internal/settings/overlay.go b/internal/settings/overlay.go new file mode 100644 index 0000000..65afe5e --- /dev/null +++ b/internal/settings/overlay.go @@ -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 +} diff --git a/internal/settings/overlay_test.go b/internal/settings/overlay_test.go new file mode 100644 index 0000000..61c29b2 --- /dev/null +++ b/internal/settings/overlay_test.go @@ -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) + } + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index ed2d655..b82f67c 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -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 { -- 2.54.0