fix(config): layer root .env over configs/.env.{ENV} for KB auth

Stop using a fake default KB password; merge Compose-style env files and
rewrite compose DNS to localhost on the host so go test/E2E authenticate.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-17 02:03:07 +00:00
co-authored by Cursor
parent e694408851
commit cdbf9cab63
2 changed files with 312 additions and 130 deletions
+188 -128
View File
@@ -93,7 +93,7 @@ func Load(path string) (*Config, error) {
KbDbHost: "127.0.0.1",
KbDbPort: 5432,
KbDbUser: "regkb",
KbDbPassword: "shared_kb_secret_pass",
KbDbPassword: "", // 禁止静默默认口令;须由根 .env / 进程环境提供
KbDbName: "regulations",
ContextDbHost: "127.0.0.1",
@@ -120,134 +120,18 @@ func Load(path string) (*Config, error) {
}
if path == "" {
if _, err := os.Stat("configs/.env.dev"); err == nil {
path = "configs/.env.dev"
} else if _, err := os.Stat(".env"); err == nil {
path = ".env"
// 与 Compose 一致:根 .env(公共密钥)+ configs/.env.{ENV}(环境差异 overlay)
_ = applyEnvFile(cfg, ".env")
envName := cfg.Env
if v := os.Getenv("ENV"); v != "" {
envName = v
}
}
f, err := os.Open(path)
if err == nil {
defer f.Close()
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
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
if commentIdx := strings.Index(val, " #"); commentIdx != -1 {
val = strings.TrimSpace(val[:commentIdx])
}
switch key {
case "ENV":
cfg.Env = val
case "DATA_DIR":
cfg.DataDir = val
case "APP_PUBLISH_HOST", "HTTP_HOST":
cfg.HTTPHost = val
case "HTTP_PORT":
if p, err := strconv.Atoi(val); err == nil {
cfg.HTTPPort = p
}
case "AGENT_BACKEND":
cfg.AgentBackend = val
case "AGENT_TIMEOUT":
if d, err := time.ParseDuration(val); err == nil {
cfg.AgentTimeout = d
}
case "CURSOR_API_KEY":
cfg.CursorAPIKey = val
case "CURSOR_MODEL":
cfg.CursorModel = val
case "CURSOR_BIN":
cfg.CursorBin = val
case "CURSOR_BRIDGE_BIN":
cfg.CursorBridgeBin = val
case "PI_AGENT_BIN":
cfg.PiAgentBin = val
case "PI_AGENT_BRIDGE_BIN":
cfg.PiAgentBridgeBin = val
case "PI_AGENT_PROVIDER":
cfg.PiAgentProvider = val
case "PI_AGENT_MODEL":
cfg.PiAgentModel = val
case "PI_AGENT_RUNNER_URL":
cfg.PiAgentRunnerURL = val
case "KB_DB_HOST":
cfg.KbDbHost = val
case "KB_DB_PORT":
if p, err := strconv.Atoi(val); err == nil {
cfg.KbDbPort = p
}
case "KB_DB_USER":
cfg.KbDbUser = val
case "KB_DB_PASSWORD":
cfg.KbDbPassword = val
case "KB_DB_NAME":
cfg.KbDbName = val
case "CONTEXT_DB_HOST":
cfg.ContextDbHost = val
case "CONTEXT_DB_PORT":
if p, err := strconv.Atoi(val); err == nil {
cfg.ContextDbPort = p
}
case "CONTEXT_DB_USER":
cfg.ContextDbUser = val
case "CONTEXT_DB_PASSWORD":
cfg.ContextDbPassword = val
case "CONTEXT_DB_NAME":
cfg.ContextDbName = val
case "EMBEDDING_ENDPOINT_URL":
cfg.EmbeddingEndpointURL = val
case "EMBEDDING_PROVIDER":
cfg.EmbeddingProvider = val
case "EMBEDDING_MODEL":
cfg.EmbeddingModel = val
case "EMBEDDING_API_KEY":
cfg.EmbeddingAPIKey = val
case "RERANKER_ENDPOINT_URL":
cfg.RerankerEndpointURL = val
case "RERANKER_PROVIDER":
cfg.RerankerProvider = val
case "RERANKER_MODEL":
cfg.RerankerModel = val
case "RERANKER_API_KEY":
cfg.RerankerAPIKey = val
case "ADMIN_USERNAME":
cfg.AdminUsername = val
case "ADMIN_PASSWORD":
cfg.AdminPassword = val
case "AUTH_REGISTRATION_ENABLED":
cfg.AuthRegistrationEnabled = val == "true" || val == "1"
case "AUTH_REQUIRE_APPROVAL":
cfg.AuthRequireApproval = val == "true" || val == "1"
case "MAX_CONCURRENT_AGENTS":
if n, err := strconv.Atoi(val); err == nil && n > 0 {
cfg.MaxConcurrentAgents = n
}
case "ALLOW_IN_MEMORY":
cfg.AllowInMemory = val == "true" || val == "1"
case "CORS_ALLOWED_ORIGINS":
cfg.CORSAllowedOrigins = parseCSVOrigins(val)
case "VAULT_STATE_ROOT":
cfg.VaultStateRoot = val
case "VAULT_STORAGE_IDENTITY":
cfg.VaultStorageIdentity = val
case "VAULT_MASTER_KEY":
cfg.VaultMasterKey = val
case "FAV_DEGRADED_READ":
cfg.FAVDegradedRead = val == "true" || val == "1"
case "FAV_PUBLISHER_ENABLED":
cfg.FAVPublisherEnabled = val == "true" || val == "1"
}
if envName == "" {
envName = "dev"
}
_ = applyEnvFile(cfg, filepath.Join("configs", ".env."+envName))
} else {
_ = applyEnvFile(cfg, path)
}
// 允许系统环境变量覆盖
@@ -406,6 +290,7 @@ func Load(path string) (*Config, error) {
}
applyDSNOverrides(cfg)
applyCORSDefaults(cfg)
NormalizeKBHostForRuntime(cfg)
if err := validateProductionSecrets(cfg); err != nil {
return nil, err
@@ -417,6 +302,162 @@ func Load(path string) (*Config, error) {
return cfg, nil
}
// applyEnvFile overlays KEY=VALUE pairs from path onto cfg. Missing file is a no-op.
func applyEnvFile(cfg *Config, path string) error {
if cfg == nil || strings.TrimSpace(path) == "" {
return nil
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
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
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
if commentIdx := strings.Index(val, " #"); commentIdx != -1 {
val = strings.TrimSpace(val[:commentIdx])
}
switch key {
case "ENV":
cfg.Env = val
case "DATA_DIR":
cfg.DataDir = val
case "APP_PUBLISH_HOST", "HTTP_HOST":
cfg.HTTPHost = val
case "HTTP_PORT":
if p, err := strconv.Atoi(val); err == nil {
cfg.HTTPPort = p
}
case "AGENT_BACKEND":
cfg.AgentBackend = val
case "AGENT_TIMEOUT":
if d, err := time.ParseDuration(val); err == nil {
cfg.AgentTimeout = d
}
case "CURSOR_API_KEY":
cfg.CursorAPIKey = val
case "CURSOR_MODEL":
cfg.CursorModel = val
case "CURSOR_BIN":
cfg.CursorBin = val
case "CURSOR_BRIDGE_BIN":
cfg.CursorBridgeBin = val
case "PI_AGENT_BIN":
cfg.PiAgentBin = val
case "PI_AGENT_BRIDGE_BIN":
cfg.PiAgentBridgeBin = val
case "PI_AGENT_PROVIDER":
cfg.PiAgentProvider = val
case "PI_AGENT_MODEL":
cfg.PiAgentModel = val
case "PI_AGENT_RUNNER_URL":
cfg.PiAgentRunnerURL = val
case "KB_DB_HOST":
cfg.KbDbHost = val
case "KB_DB_PORT":
if p, err := strconv.Atoi(val); err == nil {
cfg.KbDbPort = p
}
case "KB_DB_USER":
cfg.KbDbUser = val
case "KB_DB_PASSWORD":
cfg.KbDbPassword = val
case "KB_DB_NAME":
cfg.KbDbName = val
case "CONTEXT_DB_HOST":
cfg.ContextDbHost = val
case "CONTEXT_DB_PORT":
if p, err := strconv.Atoi(val); err == nil {
cfg.ContextDbPort = p
}
case "CONTEXT_DB_USER":
cfg.ContextDbUser = val
case "CONTEXT_DB_PASSWORD":
cfg.ContextDbPassword = val
case "CONTEXT_DB_NAME":
cfg.ContextDbName = val
case "EMBEDDING_ENDPOINT_URL":
cfg.EmbeddingEndpointURL = val
case "EMBEDDING_PROVIDER":
cfg.EmbeddingProvider = val
case "EMBEDDING_MODEL":
cfg.EmbeddingModel = val
case "EMBEDDING_API_KEY":
cfg.EmbeddingAPIKey = val
case "RERANKER_ENDPOINT_URL":
cfg.RerankerEndpointURL = val
case "RERANKER_PROVIDER":
cfg.RerankerProvider = val
case "RERANKER_MODEL":
cfg.RerankerModel = val
case "RERANKER_API_KEY":
cfg.RerankerAPIKey = val
case "ADMIN_USERNAME":
cfg.AdminUsername = val
case "ADMIN_PASSWORD":
cfg.AdminPassword = val
case "AUTH_REGISTRATION_ENABLED":
cfg.AuthRegistrationEnabled = val == "true" || val == "1"
case "AUTH_REQUIRE_APPROVAL":
cfg.AuthRequireApproval = val == "true" || val == "1"
case "MAX_CONCURRENT_AGENTS":
if n, err := strconv.Atoi(val); err == nil && n > 0 {
cfg.MaxConcurrentAgents = n
}
case "ALLOW_IN_MEMORY":
cfg.AllowInMemory = val == "true" || val == "1"
case "CORS_ALLOWED_ORIGINS":
cfg.CORSAllowedOrigins = parseCSVOrigins(val)
case "VAULT_STATE_ROOT":
cfg.VaultStateRoot = val
case "VAULT_STORAGE_IDENTITY":
cfg.VaultStorageIdentity = val
case "VAULT_MASTER_KEY":
cfg.VaultMasterKey = val
case "FAV_DEGRADED_READ":
cfg.FAVDegradedRead = val == "true" || val == "1"
case "FAV_PUBLISHER_ENABLED":
cfg.FAVPublisherEnabled = val == "true" || val == "1"
}
}
return scanner.Err()
}
// NormalizeKBHostForRuntime rewrites Compose DNS names to localhost when running
// on the host (no /.dockerenv). Inside containers the shared network name is kept.
func NormalizeKBHostForRuntime(cfg *Config) {
if cfg == nil {
return
}
if _, err := os.Stat("/.dockerenv"); err == nil {
return
}
switch strings.TrimSpace(cfg.KbDbHost) {
case "shuimu-kb-shared", "shuimu-kb":
cfg.KbDbHost = "127.0.0.1"
}
}
// RequireKbPassword returns an error when KB_DB_PASSWORD is empty (common after
// layered Load if root .env is missing). Callers that need online KB should check this.
func (c *Config) RequireKbPassword() error {
if c == nil || strings.TrimSpace(c.KbDbPassword) == "" {
return fmt.Errorf("config: KB_DB_PASSWORD is empty; provide it in root .env (layered with configs/.env.{ENV}) or process environment")
}
return nil
}
// ValidateIndependentVault refuses vault roots that collide with runtime data/{env}.
// Empty root is allowed (FAV stays unconfigured / disabled).
func ValidateIndependentVault(root, dataDir string) error {
@@ -550,11 +591,30 @@ func parsePostgresDSN(dsn string) (host string, port int, user, password, dbName
return host, port, user, password, dbName, true
}
// KbDSN 返回 PostgreSQL 连接字符串
// KbDSN 返回 PostgreSQL 连接字符串。口令为空时仍返回可解析 DSN,但不应被静默当真口令使用。
func (c *Config) KbDSN() string {
return "postgres://" + c.KbDbUser + ":" + c.KbDbPassword + "@" + c.KbDbHost + ":" + strconv.Itoa(c.KbDbPort) + "/" + c.KbDbName + "?sslmode=disable"
}
// RedactPostgresDSN masks the password segment of a postgres:// DSN for logs.
func RedactPostgresDSN(dsn string) string {
if !strings.HasPrefix(dsn, "postgres://") {
return dsn
}
rest := strings.TrimPrefix(dsn, "postgres://")
at := strings.LastIndex(rest, "@")
if at <= 0 {
return "postgres://***"
}
creds := rest[:at]
hostPart := rest[at+1:]
userPass := strings.SplitN(creds, ":", 2)
if len(userPass) != 2 {
return "postgres://***@" + hostPart
}
return "postgres://" + userPass[0] + ":***@" + hostPart
}
// ContextDSN 返回专属记忆与多级上下文 PostgreSQL 连接字符串
func (c *Config) ContextDSN() string {
return "postgres://" + c.ContextDbUser + ":" + c.ContextDbPassword + "@" + c.ContextDbHost + ":" + strconv.Itoa(c.ContextDbPort) + "/" + c.ContextDbName + "?sslmode=disable"
+124 -2
View File
@@ -3,6 +3,7 @@ package config_test
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -10,6 +11,17 @@ import (
)
func TestConfigLoadDefaultsAndFile(t *testing.T) {
t.Setenv("ENV", "")
t.Setenv("DATA_DIR", "")
t.Setenv("AGENT_BACKEND", "")
t.Setenv("AGENT_TIMEOUT", "")
t.Setenv("KB_DB_PORT", "")
t.Setenv("KB_DB_PASSWORD", "")
t.Setenv("KB_DB_HOST", "")
t.Setenv("POSTGRES_SHARED_KB_DSN", "")
t.Setenv("APP_DB_DSN", "")
t.Setenv("HTTP_ADDR", "")
tmpDir := t.TempDir()
envPath := filepath.Join(tmpDir, ".env")
envContent := `ENV=dev
@@ -58,8 +70,13 @@ func TestConfigLoadDSNOverrides(t *testing.T) {
if cfg.ContextDbHost != "system-db" || cfg.ContextDbPort != 5432 || cfg.ContextDbUser != "app_user" || cfg.ContextDbName != "agent_system_dev" {
t.Fatalf("unexpected context db config: %+v", cfg)
}
if cfg.KbDbHost != "shuimu-kb-shared" || cfg.KbDbPort != 5432 || cfg.KbDbUser != "kb_user" || cfg.KbDbName != "regulations" {
t.Fatalf("unexpected kb db config: %+v", cfg)
// 宿主机运行时会将 Compose DNS 归一化为 127.0.0.1(容器内保留原名)
wantHost := "shuimu-kb-shared"
if _, err := os.Stat("/.dockerenv"); err != nil {
wantHost = "127.0.0.1"
}
if cfg.KbDbHost != wantHost || cfg.KbDbPort != 5432 || cfg.KbDbUser != "kb_user" || cfg.KbDbName != "regulations" {
t.Fatalf("unexpected kb db config: host=%s want=%s cfg=%+v", cfg.KbDbHost, wantHost, cfg)
}
if cfg.HTTPPort != 9090 || cfg.HTTPHost != "0.0.0.0" {
t.Fatalf("expected HTTP 0.0.0.0:9090, got %s:%d", cfg.HTTPHost, cfg.HTTPPort)
@@ -184,3 +201,108 @@ func TestValidateIndependentVault(t *testing.T) {
t.Fatal(err)
}
}
func TestConfigDefaultKbPasswordEmpty(t *testing.T) {
t.Setenv("KB_DB_PASSWORD", "")
t.Setenv("KB_DB_HOST", "")
t.Setenv("POSTGRES_SHARED_KB_DSN", "")
cfg, err := config.Load("/dev/null")
if err != nil {
t.Fatal(err)
}
if cfg.KbDbPassword != "" {
t.Fatalf("expected empty default KB password, got %q", cfg.KbDbPassword)
}
if err := cfg.RequireKbPassword(); err == nil {
t.Fatal("expected RequireKbPassword to fail when empty")
}
}
func TestConfigLayeredLoadMergesRootPassword(t *testing.T) {
t.Setenv("KB_DB_PASSWORD", "")
t.Setenv("KB_DB_HOST", "")
t.Setenv("KB_DB_PORT", "")
t.Setenv("KB_DB_USER", "")
t.Setenv("POSTGRES_SHARED_KB_DSN", "")
t.Setenv("ENV", "")
t.Setenv("DATA_DIR", "")
root := t.TempDir()
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(root); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
if err := os.MkdirAll("configs", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(".env", []byte("KB_DB_PASSWORD=layered-real-pass\nKB_DB_HOST=shuimu-kb-shared\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("configs/.env.dev", []byte("ENV=dev\nKB_DB_PORT=5432\nKB_DB_USER=regkb\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := config.Load("")
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.KbDbPassword != "layered-real-pass" {
t.Fatalf("expected layered password from root .env, got %q", cfg.KbDbPassword)
}
if cfg.KbDbPassword == "shared_kb_secret_pass" {
t.Fatal("must not use obsolete insecure default password")
}
wantHost := "127.0.0.1"
if _, err := os.Stat("/.dockerenv"); err == nil {
wantHost = "shuimu-kb-shared"
}
if cfg.KbDbHost != wantHost {
t.Fatalf("expected host normalize to %s, got %s", wantHost, cfg.KbDbHost)
}
}
func TestNormalizeKBHostForRuntime(t *testing.T) {
cfg := &config.Config{KbDbHost: "shuimu-kb-shared"}
config.NormalizeKBHostForRuntime(cfg)
if _, err := os.Stat("/.dockerenv"); err == nil {
if cfg.KbDbHost != "shuimu-kb-shared" {
t.Fatalf("in docker must keep compose host, got %s", cfg.KbDbHost)
}
return
}
if cfg.KbDbHost != "127.0.0.1" {
t.Fatalf("on host expected 127.0.0.1, got %s", cfg.KbDbHost)
}
}
func TestRedactPostgresDSN(t *testing.T) {
got := config.RedactPostgresDSN("postgres://regkb:super-secret@127.0.0.1:5432/regulations?sslmode=disable")
if got != "postgres://regkb:***@127.0.0.1:5432/regulations?sslmode=disable" {
t.Fatalf("unexpected redact: %s", got)
}
if strings.Contains(got, "super-secret") {
t.Fatal("password leaked in redacted DSN")
}
}
func TestLiveLayeredLoadHasNonDefaultPassword(t *testing.T) {
if os.Getenv("CHECK_LIVE_KB_ENV") != "1" {
t.Skip("set CHECK_LIVE_KB_ENV=1 to assert workspace root .env")
}
cfg, err := config.Load("")
if err != nil {
t.Fatal(err)
}
if cfg.KbDbPassword == "" || cfg.KbDbPassword == "shared_kb_secret_pass" {
t.Fatalf("live Load missing real KB password (len=%d)", len(cfg.KbDbPassword))
}
if cfg.KbDbHost != "127.0.0.1" && cfg.KbDbHost != "shuimu-kb-shared" {
t.Fatalf("unexpected host %s", cfg.KbDbHost)
}
t.Logf("live kb host=%s port=%d password_set=true", cfg.KbDbHost, cfg.KbDbPort)
}