From 679028e44edbe57fed90027ca042adca59d38dc3 Mon Sep 17 00:00:00 2001 From: agent-bot Date: Tue, 29 Sep 2026 07:39:28 +0000 Subject: [PATCH] feat(cli): 'config explain' with per-key layer attribution (U10b slice 1) - config.Explain(envFiles, cliOverrides): per-key source layer following the application precedence cli > process-env > env-file > default; registry is reporting-only (values still applied solely by Load); secrets never shown - ParseEnvFileKeys shares applyEnvFile's parsing rules - cmd/server 'config explain' resolves the same env-file stack as Load and renders the attribution table; smoke: HTTP_PORT/AGENT_BACKEND -> cli - tests: registry liveness sample + layer attribution (file/process/cli/ default) + secret masking - full per-field snapshot with applied/desired revisions remains U10b core --- cmd/server/main.go | 70 ++++++++++++- internal/config/explain.go | 178 ++++++++++++++++++++++++++++++++ internal/config/explain_test.go | 49 +++++++++ 3 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 internal/config/explain.go create mode 100644 internal/config/explain_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index c66e349d..ea9a7cb3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -7,6 +7,9 @@ import ( "net/http" "os" "os/signal" + "path/filepath" + "strconv" + "strings" "syscall" "time" @@ -23,11 +26,17 @@ func main() { if len(os.Args) > 1 { switch os.Args[1] { case "config": - if len(os.Args) > 2 && os.Args[2] == "check" { - configCheck(os.Args[3:]) - return + if len(os.Args) > 2 { + switch os.Args[2] { + case "check": + configCheck(os.Args[3:]) + return + case "explain": + configExplain(os.Args[3:]) + return + } } - fmt.Fprintln(os.Stderr, "用法: server config check [--env path] [--port N] [--host H] [--backend name]") + fmt.Fprintln(os.Stderr, "用法: server config check|explain [--env path] [--port N] [--host H] [--backend name]") os.Exit(2) case "-h", "--help", "help": fmt.Println("用法: server [serve flags] 启动服务(默认)\n server config check [flags] 加载并校验配置(脱敏摘要,无副作用)") @@ -174,7 +183,6 @@ func shutdownDrainTimeout(cfg *config.Config) time.Duration { return at + 30*time.Second } - // configCheck 加载并校验配置:输出脱敏摘要与问题清单。 // 不创建 DB、不启动服务、不写业务目录(KTD17 config check 约束)。 func configCheck(args []string) { @@ -210,3 +218,55 @@ func configCheck(args []string) { } fmt.Println("config check: 通过") } + +// configExplain 解释每个配置键的最终来源层(KTD17 第一片): +// cli > process-env > env-file > default;秘密值只显示 set 状态。 +func configExplain(args []string) { + fs := flag.NewFlagSet("config explain", flag.ExitOnError) + envPath := fs.String("env", "", "环境配置文件路径(默认根 .env + configs/.env.{ENV} 叠加)") + port := fs.Int("port", 0, "HTTP 端口覆盖") + host := fs.String("host", "", "HTTP 主机覆盖") + backend := fs.String("backend", "", "智能体后端覆盖") + _ = fs.Parse(args) + + var envFiles []string + if *envPath != "" { + envFiles = append(envFiles, *envPath) + } else { + envFiles = append(envFiles, ".env") + envName := "dev" + if keys, err := config.ParseEnvFileKeys(".env"); err == nil { + for _, k := range keys { + if k == "ENV" { + // 从文件行取值 + if b, rerr := os.ReadFile(".env"); rerr == nil { + for _, line := range strings.Split(string(b), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "ENV=") { + envName = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "ENV=")) + } + } + } + break + } + } + } + if v := os.Getenv("ENV"); v != "" { + envName = v + } + envFiles = append(envFiles, filepath.Join("configs", ".env."+envName)) + } + + cli := map[string]string{} + if *port > 0 { + cli["HTTP_PORT"] = strconv.Itoa(*port) + } + if *host != "" { + cli["HTTP_HOST"] = *host + } + if *backend != "" { + cli["AGENT_BACKEND"] = *backend + } + + fmt.Println("env files:", strings.Join(envFiles, ", ")) + fmt.Print(config.FormatExplanation(config.Explain(envFiles, cli))) +} diff --git a/internal/config/explain.go b/internal/config/explain.go new file mode 100644 index 00000000..53ca0c04 --- /dev/null +++ b/internal/config/explain.go @@ -0,0 +1,178 @@ +package config + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// knownEnvKeys 是配置环境键注册表(U10b 第一片:reporting-only)。 +// 仅用于 config explain 的来源解释,不参与取值——取值仍由 Load 的 +// 应用逻辑唯一决定;新增环境键时须同步登记,漂移由抽样测试约束。 +var knownEnvKeys = map[string]bool{ + "ADMIN_PASSWORD": true, + "ADMIN_USERNAME": true, + "AGENTX_API_BASE": true, + "AGENTX_API_KEY": true, + "AGENTX_MODEL": true, + "AGENTX_TIMEOUT": true, + "AGENT_BACKEND": true, + "AGENT_TIMEOUT": true, + "ALLOW_IN_MEMORY": true, + "APP_DB_DSN": true, + "APP_PUBLISH_HOST": true, + "AUTH_REGISTRATION_ENABLED": true, + "AUTH_REQUIRE_APPROVAL": true, + "CONTEXT_DB_HOST": true, + "CONTEXT_DB_NAME": true, + "CONTEXT_DB_PASSWORD": true, + "CONTEXT_DB_PORT": true, + "CONTEXT_DB_USER": true, + "CORS_ALLOWED_ORIGINS": true, + "DATA_DIR": true, + "EMBEDDING_API_KEY": true, + "EMBEDDING_ENDPOINT_URL": true, + "EMBEDDING_MODEL": true, + "EMBEDDING_PROVIDER": true, + "ENV": true, + "FAV_DEGRADED_READ": true, + "FAV_PUBLISHER_ENABLED": true, + "HTTP_ADDR": true, + "HTTP_HOST": true, + "HTTP_PORT": true, + "JEV_API_KEY": true, + "JEV_BASE_URL": true, + "JEV_DECIDE_MODE": true, + "KB_DB_HOST": true, + "KB_DB_NAME": true, + "KB_DB_PASSWORD": true, + "KB_DB_PORT": true, + "KB_DB_USER": true, + "MATERIAL_FULLTEXT_MAX_BYTES": true, + "MATERIAL_RAG_TOP_K": true, + "MAX_CONCURRENT_AGENTS": true, + "MINERU_API_KEY": true, + "MINERU_ENDPOINT_URL": true, + "MINERU_LANGUAGE": true, + "MINERU_MODEL_VERSION": true, + "MINERU_TIMEOUT": true, + "MINIMAX_API_KEY": true, + "MINIMAX_ENDPOINT_URL": true, + "PI_AGENT_BIN": true, + "PI_AGENT_BRIDGE_BIN": true, + "PI_AGENT_MODEL": true, + "PI_AGENT_PROVIDER": true, + "PI_AGENT_RUNNER_AUTH_TOKEN": true, + "PI_AGENT_RUNNER_URL": true, + "POSTGRES_SHARED_KB_DSN": true, + "RERANKER_API_KEY": true, + "RERANKER_ENDPOINT_URL": true, + "RERANKER_MODEL": true, + "RERANKER_PROVIDER": true, + "RUNNER_AUTH_TOKEN": true, + "VAULT_MASTER_KEY": true, + "VAULT_STATE_ROOT": true, + "VAULT_STORAGE_IDENTITY": true, + "WORKSPACE_QUOTA_BYTES": true, + "WORKSPACE_TTL": true, +} + +// ExplainLayer 是配置键的一个来源层。 +type ExplainLayer = string + +const ( + LayerDefault ExplainLayer = "default" + LayerEnvFile ExplainLayer = "env-file" + LayerProcessEnv ExplainLayer = "process-env" + LayerCLI ExplainLayer = "cli" +) + +// ExplainEntry 是单个配置键的解释行。 +type ExplainEntry struct { + Key string + Layer ExplainLayer + Value string // 秘密键只显示 /;env-file 层显示文件路径 +} + +// ParseEnvFileKeys 用与 applyEnvFile 相同的解析规则列出文件中出现的键。 +func ParseEnvFileKeys(path string) ([]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + var keys []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + keys = append(keys, strings.TrimSpace(parts[0])) + } + return keys, scanner.Err() +} + +// Explain 生成各配置键的来源层解释(KTD17 config explain 第一片)。 +// envFiles 按加载顺序(后者覆盖前者);cliOverrides 为 CLI 显式覆盖键。 +// 层序与生效优先级一致:cli > process-env > env-file > default。 +// 非秘密且来源为 env-file/process-env 的键展示脱敏值;秘密键只显示 set 状态。 +func Explain(envFiles []string, cliOverrides map[string]string) []ExplainEntry { + fileKeys := map[string]string{} // key -> 首个出现的文件 + for _, p := range envFiles { + keys, err := ParseEnvFileKeys(p) + if err != nil { + continue + } + for _, k := range keys { + fileKeys[k] = filepath.ToSlash(p) + } + } + + var out []ExplainEntry + for k := range knownEnvKeys { + e := ExplainEntry{Key: k, Layer: LayerDefault, Value: ""} + if src, ok := fileKeys[k]; ok { + e.Layer = LayerEnvFile + e.Value = src + if v := os.Getenv(k); v != "" { + e.Layer = LayerProcessEnv + e.Value = maskedValue(v) + } + } else if v := os.Getenv(k); v != "" { + e.Layer = LayerProcessEnv + e.Value = maskedValue(v) + } + if v, ok := cliOverrides[k]; ok { + e.Layer = LayerCLI + e.Value = maskedValue(v) + } + out = append(out, e) + } + sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) + return out +} + +func maskedValue(v string) string { + if strings.TrimSpace(v) == "" { + return "" + } + return "" +} + +// FormatExplanation 渲染 explain 表(供 CLI 输出与测试断言)。 +func FormatExplanation(entries []ExplainEntry) string { + var b strings.Builder + b.WriteString("key layer detail\n") + for _, e := range entries { + fmt.Fprintf(&b, "%-37s %-12s %s\n", e.Key, e.Layer, e.Value) + } + return b.String() +} diff --git a/internal/config/explain_test.go b/internal/config/explain_test.go new file mode 100644 index 00000000..ab293a6f --- /dev/null +++ b/internal/config/explain_test.go @@ -0,0 +1,49 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// 注册表键必须真实影响 Load(抽样防 reporting-only 注册表漂移)。 +func TestExplainRegistryKeysAreLive(t *testing.T) { + t.Setenv("HTTP_PORT", "9911") + t.Setenv("AGENT_BACKEND", "stub") + cfg, err := Load("") + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.HTTPPort != 9911 || cfg.AgentBackend != "stub" { + t.Fatalf("registry sample keys not applied: port=%d backend=%s", cfg.HTTPPort, cfg.AgentBackend) + } +} + +func TestExplainLayerAttribution(t *testing.T) { + dir := t.TempDir() + envf := filepath.Join(dir, ".env") + if err := os.WriteFile(envf, []byte("HTTP_HOST=0.0.0.0\nKB_DB_PASSWORD=from-file\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("KB_DB_PASSWORD", "from-process") // 进程 env 覆盖 env-file + entries := Explain([]string{envf}, map[string]string{"AGENT_BACKEND": "stub"}) + by := map[string]ExplainEntry{} + for _, e := range entries { + by[e.Key] = e + } + if by["HTTP_HOST"].Layer != LayerEnvFile { + t.Fatalf("HTTP_HOST layer=%s want env-file", by["HTTP_HOST"].Layer) + } + if by["KB_DB_PASSWORD"].Layer != LayerProcessEnv { + t.Fatalf("KB_DB_PASSWORD layer=%s want process-env", by["KB_DB_PASSWORD"].Layer) + } + if by["KB_DB_PASSWORD"].Value != "" { + t.Fatalf("secret value leaked: %q", by["KB_DB_PASSWORD"].Value) + } + if by["AGENT_BACKEND"].Layer != LayerCLI { + t.Fatalf("AGENT_BACKEND layer=%s want cli", by["AGENT_BACKEND"].Layer) + } + if by["ADMIN_USERNAME"].Layer != LayerDefault { + t.Fatalf("ADMIN_USERNAME layer=%s want default", by["ADMIN_USERNAME"].Layer) + } +}