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
This commit is contained in:
weichen
2026-09-30 04:19:11 +00:00
parent 6e515dacc1
commit 66b4bbf55a
7 changed files with 468 additions and 3 deletions
+7
View File
@@ -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 <json>`,无自动发现)→ 环境变量(`AGENT_SYSTEM_ADDR/DATA/PLUGINS/DSN/ARTIFACTS/AUTH/MOCK`)→ 显式 CLI(未传入的 flag 不覆盖)。
- 只读诊断:`agent-system config explain [--config …] [--<flag> …]`——逐字段打印解析值与来源,DSN 脱敏;不建库、不写目录。
- 多实例:`envs/{dev,test,prod}.json` 为隔离示例(data 根/DB 不得重叠);实例约定详见 Documents 仓 `docs/proposals/2026-09-30-configuration-surface.md`。
- 运行时业务设置(`/api/settings` user/project scope、profiles 路由)是独立的业务策略层,不参与启动合并。
+47 -3
View File
@@ -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", "", "工件对象目录(默认 <data>/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 遇位置参数即停)。
+5
View File
@@ -0,0 +1,5 @@
{
"addr": "127.0.0.1:8787",
"data": "./data-dev",
"mock": true
}
+7
View File
@@ -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"
}
+5
View File
@@ -0,0 +1,5 @@
{
"addr": "127.0.0.1:8788",
"data": "./data-test",
"auth": "off"
}
+247
View File
@@ -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
}
+150
View File
@@ -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)
}
}