From 66b4bbf55a6cf24b60a6d1d945825b0b44503f5f Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 04:18:22 +0000 Subject: [PATCH] Add the startup configuration pipe with file and env layers plus explain Why: startup had only defaults and CLI flags; dev/test/prod operation needs the standard baseline (explicit file, environment, explicit-only CLI) and a read-only diagnostic. Evidence: internal/config unit tests cover precedence, unset-CLI no-override, presence-based file semantics, unknown-key/missing-file errors, env bool parsing, explain source labelling and DSN redaction; full suite 12 packages pass; live run of config explain with envs/prod.json shows correct layer attribution and redaction. Impact: serve gains --config and AGENT_SYSTEM_* env overrides (behavior unchanged when neither is used); new config explain subcommand; envs/ examples and PROJECT.md configuration section. Refs: documents docs/proposals/2026-09-30-configuration-surface.md; standards/configuration.md --- PROJECT.md | 7 + cmd/agent-system/main.go | 50 ++++++- envs/dev.json | 5 + envs/prod.json | 7 + envs/test.json | 5 + internal/config/config.go | 247 +++++++++++++++++++++++++++++++++ internal/config/config_test.go | 150 ++++++++++++++++++++ 7 files changed, 468 insertions(+), 3 deletions(-) create mode 100644 envs/dev.json create mode 100644 envs/prod.json create mode 100644 envs/test.json create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/PROJECT.md b/PROJECT.md index 1525162..83ed98f 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -11,3 +11,10 @@ - `CGO_ENABLED=0 go build ./cmd/agent-system` — 单二进制(静态,内嵌前端后)。 - 存储:嵌入式 SQLite(modernc.org/sqlite,纯 Go);FTS5 全文 + CJK LIKE 兜底。 - 依赖:agent-system-core(deps submodule + replace)、agent-native(runtime/llm 复用)。 + +## 配置(启动层) + +- 来源优先级:内置默认 → 显式配置文件(`--config `,无自动发现)→ 环境变量(`AGENT_SYSTEM_ADDR/DATA/PLUGINS/DSN/ARTIFACTS/AUTH/MOCK`)→ 显式 CLI(未传入的 flag 不覆盖)。 +- 只读诊断:`agent-system config explain [--config …] [-- …]`——逐字段打印解析值与来源,DSN 脱敏;不建库、不写目录。 +- 多实例:`envs/{dev,test,prod}.json` 为隔离示例(data 根/DB 不得重叠);实例约定详见 Documents 仓 `docs/proposals/2026-09-30-configuration-surface.md`。 +- 运行时业务设置(`/api/settings` user/project scope、profiles 路由)是独立的业务策略层,不参与启动合并。 diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index 5ae5417..cd253ab 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -1,7 +1,7 @@ // agent-system:单二进制服务(HTTP/SSE + 内嵌前端 + 双档存储)。 // 部署约束(设计 4.1 原则 8):非容器化;拷贝即运行。 // 嵌入式档默认(SQLite + 本地对象目录,零外部服务);--dsn 切换 PostgreSQL 档 -//(多用户/多实例,认证默认开启,apikey 子命令管理密钥)。 +// (多用户/多实例,认证默认开启,apikey 子命令管理密钥)。 package main import ( @@ -11,6 +11,7 @@ import ( "os" "agent-system-backend/internal/app" + "agent-system-backend/internal/config" ) var version = "0.1.0-dev" @@ -25,9 +26,12 @@ func main() { os.Args = append([]string{os.Args[0]}, os.Args[2:]...) case "apikey": os.Exit(runAPIKey(os.Args[2:])) + case "config": + os.Exit(runConfig(os.Args[2:])) } } fs := flag.NewFlagSet("serve", flag.ExitOnError) + cfgFile := fs.String("config", "", "显式配置文件(JSON,键见 config explain;无自动发现)") addr := fs.String("addr", "127.0.0.1:8787", "监听地址") data := fs.String("data", "./data", "数据目录(SQLite 与工件对象目录)") pluginsDir := fs.String("plugins", "./plugins", "插件目录(声明资产热加载)") @@ -36,16 +40,56 @@ func main() { artifacts := fs.String("artifacts", "", "工件对象目录(默认 /artifacts;PG 档多实例时指向共享卷)") auth := fs.String("auth", "auto", "API key 认证:auto(PG 档开、嵌入式关)| on | off") fs.Parse(os.Args[1:]) + cliSet := map[string]bool{} + fs.Visit(func(f *flag.Flag) { cliSet[f.Name] = true }) - if err := app.Run(app.Options{ - Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, ForceMock: *mock, + resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{ + Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock, StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, + }, cliSet) + if 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, }); err != nil { fmt.Fprintln(os.Stderr, "agent-system:", err) os.Exit(1) } } +// runConfig:config explain——只读解析诊断(不建库、不写业务目录;DSN 脱敏)。 +func runConfig(args []string) int { + if len(args) == 0 || args[0] != "explain" { + fmt.Fprintln(os.Stderr, "用法:agent-system config explain [--config f.json] [--addr …](其余 serve 同名 flag 用于演示覆盖)") + return 2 + } + fs := flag.NewFlagSet("config explain", flag.ExitOnError) + cfgFile := fs.String("config", "", "显式配置文件(JSON)") + addr := fs.String("addr", "127.0.0.1:8787", "") + data := fs.String("data", "./data", "") + pluginsDir := fs.String("plugins", "./plugins", "") + mock := fs.Bool("mock", false, "") + dsn := fs.String("dsn", "", "") + artifacts := fs.String("artifacts", "", "") + auth := fs.String("auth", "auto", "") + fs.Parse(args[1:]) + cliSet := map[string]bool{} + fs.Visit(func(f *flag.Flag) { cliSet[f.Name] = true }) + resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{ + Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock, + StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, + }, cliSet) + if err != nil { + fmt.Fprintln(os.Stderr, "agent-system:", err) + return 1 + } + resolved.Explain(os.Stdout, *cfgFile) + return 0 +} + // runAPIKey:密钥管理子命令(create/list/revoke)。 func runAPIKey(args []string) int { // 子命令在前、flag 在后:先剥出子命令再解析(Go flag 遇位置参数即停)。 diff --git a/envs/dev.json b/envs/dev.json new file mode 100644 index 0000000..30ca676 --- /dev/null +++ b/envs/dev.json @@ -0,0 +1,5 @@ +{ + "addr": "127.0.0.1:8787", + "data": "./data-dev", + "mock": true +} diff --git a/envs/prod.json b/envs/prod.json new file mode 100644 index 0000000..cde75ac --- /dev/null +++ b/envs/prod.json @@ -0,0 +1,7 @@ +{ + "addr": "0.0.0.0:8787", + "data": "/var/lib/agent-system", + "dsn": "postgres://agent:CHANGE_ME@127.0.0.1:5432/agent_system", + "artifacts": "/var/lib/agent-system-artifacts", + "auth": "on" +} diff --git a/envs/test.json b/envs/test.json new file mode 100644 index 0000000..c595ee5 --- /dev/null +++ b/envs/test.json @@ -0,0 +1,5 @@ +{ + "addr": "127.0.0.1:8788", + "data": "./data-test", + "auth": "off" +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..951e9f1 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,247 @@ +// Package config 提供 agent-system 启动层配置的单一解析管道: +// 内置默认 → 显式配置文件 → 环境变量 → 显式 CLI。 +// 规范依据:Development-Guidelines standards/configuration.md(单一解析管道/启动依赖); +// 运行时业务设置(/api/settings)是独立的"产品业务策略"层,不在此合并。 +// 只读诊断见 Resolved.Explain:不建数据库、不写业务目录,DSN 脱敏输出。 +package config + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" +) + +// Config 启动部署连接类字段(与 app.Options 一一对应)。 +type Config struct { + Addr string `json:"addr"` + DataDir string `json:"data"` + PluginsDir string `json:"plugins"` + StorageDSN string `json:"dsn"` + ArtifactsDir string `json:"artifacts"` + AuthMode string `json:"auth"` + Mock bool `json:"mock"` +} + +var defaults = Config{ + Addr: "127.0.0.1:8787", + DataDir: "./data", + PluginsDir: "./plugins", + AuthMode: "auto", +} + +// envKey 字段 → 环境变量名(AGENT_SYSTEM_ 前缀)。 +var envKey = map[string]string{ + "addr": "AGENT_SYSTEM_ADDR", + "data": "AGENT_SYSTEM_DATA", + "plugins": "AGENT_SYSTEM_PLUGINS", + "dsn": "AGENT_SYSTEM_DSN", + "artifacts": "AGENT_SYSTEM_ARTIFACTS", + "auth": "AGENT_SYSTEM_AUTH", + "mock": "AGENT_SYSTEM_MOCK", +} + +// Source 配置来源层级(数值越大优先级越高)。 +type Source int + +const ( + SrcDefault Source = iota + SrcFile + SrcEnv + SrcCLI +) + +func (s Source) String() string { + switch s { + case SrcFile: + return "file" + case SrcEnv: + return "env" + case SrcCLI: + return "cli" + } + return "default" +} + +// Resolved 解析结果:配置快照 + 每字段来源(供 explain 与测试断言)。 +type Resolved struct { + Config + sources map[string]Source +} + +// SourceOf 返回字段来源;未知字段名返回 SrcDefault。 +func (r Resolved) SourceOf(field string) Source { return r.sources[field] } + +// Load 合并四源。file 为空表示无配置文件层;非空而缺失/未知键/类型错误均为错误, +// 错误信息须定位到字段。cliSet 记录显式传入的 CLI 字段(flag.Visit 语义): +// 未显式传入的 CLI 字段不参与覆盖(区分"未设置"与零值)。 +func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet map[string]bool) (Resolved, error) { + cfg := defaults + src := map[string]Source{} + for f := range envKey { + src[f] = SrcDefault + } + if file != "" { + fc, present, err := loadFile(file) + if err != nil { + return Resolved{}, err + } + // 文件层按"键存在"覆盖(区分未出现与显式 false/空串;空值有效性由字段语义判定)。 + for f := range present { + if f == "mock" { + cfg.Mock = fc.Mock + } else { + setString(&cfg, f, stringField(fc, f)) + } + src[f] = SrcFile + } + } + // 环境层:变量存在即参与覆盖(空串按显式空值处理,字段 schema 自行判定)。 + for f, key := range envKey { + if v, ok := envLookup(key); ok { + if f == "mock" { + b, err := parseBool(key, v) + if err != nil { + return Resolved{}, err + } + cfg.Mock = b + } else { + setString(&cfg, f, v) + } + src[f] = SrcEnv + } + } + // CLI 层:仅显式传入。 + for f := range cliSet { + src[f] = SrcCLI + if f == "mock" { + cfg.Mock = cli.Mock + } else { + setString(&cfg, f, stringField(cli, f)) + } + } + return Resolved{Config: cfg, sources: src}, nil +} + +func loadFile(path string) (Config, map[string]bool, error) { + raw, err := os.ReadFile(path) + if err != nil { + return Config{}, nil, fmt.Errorf("config file %s: %w", path, err) + } + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err != nil { + return Config{}, nil, fmt.Errorf("config file %s: %w", path, err) + } + present := map[string]bool{} + var c Config + for k, v := range m { + var dst any + switch k { + case "addr": + dst = &c.Addr + case "data": + dst = &c.DataDir + case "plugins": + dst = &c.PluginsDir + case "dsn": + dst = &c.StorageDSN + case "artifacts": + dst = &c.ArtifactsDir + case "auth": + dst = &c.AuthMode + case "mock": + dst = &c.Mock + default: + return Config{}, nil, fmt.Errorf("config file %s: unknown key %q", path, k) + } + if err := json.Unmarshal(v, dst); err != nil { + return Config{}, nil, fmt.Errorf("config file %s: key %q: %w", path, k, err) + } + present[k] = true + } + return c, present, nil +} + +func stringField(c Config, f string) string { + switch f { + case "addr": + return c.Addr + case "data": + return c.DataDir + case "plugins": + return c.PluginsDir + case "dsn": + return c.StorageDSN + case "artifacts": + return c.ArtifactsDir + case "auth": + return c.AuthMode + } + return "" +} + +func setString(c *Config, f, v string) { + switch f { + case "addr": + c.Addr = v + case "data": + c.DataDir = v + case "plugins": + c.PluginsDir = v + case "dsn": + c.StorageDSN = v + case "artifacts": + c.ArtifactsDir = v + case "auth": + c.AuthMode = v + } +} + +func parseBool(name, v string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(v)) { + case "true", "1": + return true, nil + case "false", "0": + return false, nil + } + return false, fmt.Errorf("env %s: invalid bool %q", name, v) +} + +// Explain 只读诊断:逐字段打印解析值与来源层级,DSN 脱敏。 +func (r Resolved) Explain(w io.Writer, file string) { + if file != "" { + fmt.Fprintf(w, "config file: %s\n", file) + } else { + fmt.Fprintln(w, "config file: (none)") + } + fmt.Fprintln(w, "field value source") + for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock"} { + v := stringField(r.Config, f) + if f == "mock" { + v = fmt.Sprintf("%t", r.Mock) + } + if f == "dsn" { + v = redact(v) + } + fmt.Fprintf(w, "%-10s %-56s %s\n", f, v, r.sources[f]) + } +} + +// redact 脱敏 DSN:scheme://user:password@… → scheme://user:***@…。 +func redact(dsn string) string { + i := strings.Index(dsn, "://") + if i < 0 { + return dsn + } + rest := dsn[i+3:] + at := strings.Index(rest, "@") + if at < 0 { + return dsn + } + userpart := rest[:at] + if c := strings.Index(userpart, ":"); c >= 0 { + return dsn[:i+3] + userpart[:c] + ":***" + rest[at:] + } + return dsn +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..32b3b57 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,150 @@ +package config + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func noEnv(string) (string, bool) { return "", false } + +func fileWith(t *testing.T, content string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "c.json") + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +// 基线:无文件无环境无 CLI → 全默认。 +func TestDefaults(t *testing.T) { + r, err := Load("", noEnv, Config{}, nil) + if err != nil { + t.Fatal(err) + } + if r.Addr != "127.0.0.1:8787" || r.DataDir != "./data" || r.AuthMode != "auto" || r.Mock { + t.Fatalf("defaults wrong: %+v", r.Config) + } + if r.SourceOf("addr") != SrcDefault { + t.Fatalf("addr source = %v", r.SourceOf("addr")) + } +} + +// 四源优先级:文件 < 环境 < 显式 CLI。 +func TestPrecedence(t *testing.T) { + f := fileWith(t, `{"addr":"127.0.0.1:5000","auth":"off"}`) + env := func(k string) (string, bool) { + if k == "AGENT_SYSTEM_ADDR" { + return "127.0.0.1:6000", true + } + return "", false + } + r, err := Load(f, env, Config{Addr: "127.0.0.1:7000", AuthMode: "on"}, map[string]bool{"addr": true, "auth": true}) + if err != nil { + t.Fatal(err) + } + if r.Addr != "127.0.0.1:7000" || r.SourceOf("addr") != SrcCLI { + t.Fatalf("addr should be CLI: %q %v", r.Addr, r.SourceOf("addr")) + } + if r.AuthMode != "on" || r.SourceOf("auth") != SrcCLI { + t.Fatalf("auth should be CLI: %q %v", r.AuthMode, r.SourceOf("auth")) + } +} + +// 未显式传入的 CLI 字段不参与覆盖(区分"未设置"与零值)。 +func TestUnsetCLIDoesNotOverride(t *testing.T) { + env := func(k string) (string, bool) { + if k == "AGENT_SYSTEM_ADDR" { + return "127.0.0.1:6000", true + } + return "", false + } + // cli.Addr 是零值 "" 但未在 cliSet 中:不得覆盖环境值。 + r, err := Load("", env, Config{}, nil) + if err != nil { + t.Fatal(err) + } + if r.Addr != "127.0.0.1:6000" || r.SourceOf("addr") != SrcEnv { + t.Fatalf("addr should stay env: %q %v", r.Addr, r.SourceOf("addr")) + } +} + +// 文件层按键存在覆盖:mock:false 显式出现也生效。 +func TestFilePresenceSemantics(t *testing.T) { + f := fileWith(t, `{"mock":true,"data":"./d2"}`) + r, err := Load(f, noEnv, Config{}, nil) + if err != nil { + t.Fatal(err) + } + if !r.Mock || r.DataDir != "./d2" || r.SourceOf("mock") != SrcFile { + t.Fatalf("file layer wrong: %+v mock_src=%v", r.Config, r.SourceOf("mock")) + } +} + +// 未知键:错误须定位字段名。 +func TestUnknownKey(t *testing.T) { + f := fileWith(t, `{"bogus":1}`) + _, err := Load(f, noEnv, Config{}, nil) + if err == nil || !bytes.Contains([]byte(err.Error()), []byte(`unknown key "bogus"`)) { + t.Fatalf("want unknown-key error, got %v", err) + } +} + +// 显式传入而缺失:报错。 +func TestMissingFile(t *testing.T) { + _, err := Load("/nonexistent/c.json", noEnv, Config{}, nil) + if err == nil { + t.Fatal("missing file must error") + } +} + +// 布尔环境变量:非法值报错,true/false/1/0 合法。 +func TestEnvBool(t *testing.T) { + env := func(k string) (string, bool) { + if k == "AGENT_SYSTEM_MOCK" { + return "true", true + } + return "", false + } + r, err := Load("", env, Config{}, nil) + if err != nil || !r.Mock { + t.Fatalf("env bool: %v %+v", err, r.Config) + } + bad := func(k string) (string, bool) { + if k == "AGENT_SYSTEM_MOCK" { + return "yes", true + } + return "", false + } + if _, err := Load("", bad, Config{}, nil); err == nil { + t.Fatal("invalid bool must error") + } +} + +// explain:来源标注 + DSN 脱敏;只读(不落任何盘外文件)。 +func TestExplainRedactsDSN(t *testing.T) { + env := func(k string) (string, bool) { + if k == "AGENT_SYSTEM_DSN" { + return "postgres://u:secret@h:5432/db", true + } + return "", false + } + r, err := Load("", env, Config{}, nil) + if err != nil { + t.Fatal(err) + } + var b bytes.Buffer + r.Explain(&b, "") + out := b.String() + if bytes.Contains([]byte(out), []byte("secret")) { + t.Fatalf("dsn not redacted: %s", out) + } + if !bytes.Contains([]byte(out), []byte("u:***@h")) { + t.Fatalf("redact form wrong: %s", out) + } + if !bytes.Contains([]byte(out), []byte("env")) { + t.Fatalf("source column missing: %s", out) + } +}