package config import ( "bufio" "fmt" "os" "path/filepath" "strconv" "strings" "time" ) type Config struct { Env string `json:"env"` DataDir string `json:"data_dir"` HTTPHost string `json:"http_host"` HTTPPort int `json:"http_port"` AgentBackend string `json:"agent_backend"` AgentTimeout time.Duration `json:"agent_timeout"` AgentXAPIBase string `json:"agentx_api_base"` AgentXAPIKey string `json:"agentx_api_key"` AgentXModel string `json:"agentx_model"` AgentXTimeout time.Duration `json:"agentx_timeout"` WorkspaceTTL time.Duration `json:"workspace_ttl"` WorkspaceQuotaBytes int64 `json:"workspace_quota_bytes"` PiAgentBin string `json:"pi_agent_bin"` PiAgentBridgeBin string `json:"pi_agent_bridge_bin"` PiAgentProvider string `json:"pi_agent_provider"` PiAgentModel string `json:"pi_agent_model"` PiAgentRunnerURL string `json:"pi_agent_runner_url"` PiAgentRunnerAuthToken string `json:"pi_agent_runner_auth_token"` KbDbHost string `json:"kb_db_host"` KbDbPort int `json:"kb_db_port"` KbDbUser string `json:"kb_db_user"` KbDbPassword string `json:"kb_db_password"` KbDbName string `json:"kb_db_name"` // 专属记忆与多级上下文数据库配置 ContextDbHost string `json:"context_db_host"` ContextDbPort int `json:"context_db_port"` ContextDbUser string `json:"context_db_user"` ContextDbPassword string `json:"context_db_password"` ContextDbName string `json:"context_db_name"` // 向量化 Embedding 服务配置 EmbeddingEndpointURL string `json:"embedding_endpoint_url"` EmbeddingProvider string `json:"embedding_provider"` EmbeddingModel string `json:"embedding_model"` EmbeddingAPIKey string `json:"embedding_api_key"` // 语义重排 Reranker 服务配置 RerankerEndpointURL string `json:"reranker_endpoint_url"` RerankerProvider string `json:"reranker_provider"` RerankerModel string `json:"reranker_model"` RerankerAPIKey string `json:"reranker_api_key"` // MinerU 文档解析(精准 API);未配置 API Key 时整条链路降级为本地提取 MineruEndpointURL string `json:"mineru_endpoint_url"` MineruAPIKey string `json:"mineru_api_key"` MineruModelVersion string `json:"mineru_model_version"` MineruLanguage string `json:"mineru_language"` MineruTimeout time.Duration `json:"mineru_timeout"` // jev-service typed decide(产品侧 shadow/assist;默认 off) JevDecideMode string `json:"jev_decide_mode"` JevBaseURL string `json:"jev_base_url"` JevAPIKey string `json:"jev_api_key"` // 材料注入策略:小文档全文,大文档 RAG MaterialFulltextMaxBytes int `json:"material_fulltext_max_bytes"` MaterialRAGTopK int `json:"material_rag_top_k"` // 系统管理员与准入认证配置 AdminUsername string `json:"admin_username"` AdminPassword string `json:"admin_password"` AuthRegistrationEnabled bool `json:"auth_registration_enabled"` AuthRequireApproval bool `json:"auth_require_approval"` // Agent 全局并发上限(保护 pi-agent / GPU / API) MaxConcurrentAgents int `json:"max_concurrent_agents"` // AllowInMemory 显式允许数据库不可用时降级内存模式(仅限本地 Demo) AllowInMemory bool `json:"allow_in_memory"` // CORSAllowedOrigins 允许的浏览器 Origin 列表;空列表时不回显 Access-Control-Allow-Origin CORSAllowedOrigins []string `json:"cors_allowed_origins"` // Final Artifact Vault (FAV) — defaults stay off until separate approval. VaultStateRoot string `json:"vault_state_root"` VaultStorageIdentity string `json:"vault_storage_identity"` VaultMasterKey string `json:"vault_master_key"` FAVDegradedRead bool `json:"fav_degraded_read"` // FAV_DEGRADED_READ; default false FAVPublisherEnabled bool `json:"fav_publisher_enabled"` // FAV_PUBLISHER_ENABLED; default false HTTPAccessLogEnabled bool `json:"http_access_log_enabled"` // HTTP_ACCESS_LOG; default on for dev/test } // DefaultDataDir is the only production default for the environment data root. // Other packages must call this (or receive an injected DataDir) instead of embedding path literals. func DefaultDataDir() string { return "./data/dev" } // Load 加载配置(行为与历史版本一致;CLI 覆盖由调用方自行追加)。 func Load(path string) (*Config, error) { cfg, _, err := LoadWithSources(path, nil) return cfg, err } // LoadForServe 供服务入口使用:CLI 覆盖在实例设置层之前应用(KTD17 层序)。 func LoadForServe(path string, cliMutate func(*Config)) (*Config, map[string]FieldSource, error) { return LoadWithSources(path, cliMutate) } // LoadWithSources 加载配置并给出字段级来源(KTD17 U10b)。 // 层序:default → env-file(按文件顺序)→ process-env → cli(可选钩子)→ // instance-settings(SETTINGS_MODE=ignore 时跳过);最终整体校验。 func LoadWithSources(path string, cliMutate func(*Config)) (*Config, map[string]FieldSource, error) { var phases []struct { Layer string Snap map[string]string } cfg := &Config{ Env: "dev", DataDir: DefaultDataDir(), HTTPHost: "127.0.0.1", HTTPPort: 8080, AgentBackend: "pi-agent", AgentTimeout: 30 * time.Minute, AgentXAPIBase: "https://api.minimaxi.com/v1", AgentXModel: "MiniMax-M2.7-highspeed", AgentXTimeout: 30 * time.Minute, WorkspaceTTL: 7 * 24 * time.Hour, WorkspaceQuotaBytes: 20 << 30, PiAgentBin: "/opt/homebrew/bin/pi", PiAgentBridgeBin: "tools/pi-bridge/index.mjs", PiAgentProvider: "minimax", PiAgentModel: "MiniMax-M2.7-highspeed", KbDbHost: "127.0.0.1", KbDbPort: 5432, KbDbUser: "regkb", KbDbPassword: "", // 禁止静默默认口令;须由根 .env / 进程环境提供 KbDbName: "regulations", ContextDbHost: "127.0.0.1", ContextDbPort: 5433, ContextDbUser: "app", ContextDbPassword: "dev_secret_db_pass", ContextDbName: "agent_system_dev", EmbeddingEndpointURL: "https://www.dmxapi.cn/v1/embeddings", EmbeddingProvider: "openai_compatible", EmbeddingModel: "bge-m3", EmbeddingAPIKey: "", RerankerEndpointURL: "https://www.dmxapi.cn/v1/rerank", RerankerProvider: "openai_compatible", RerankerModel: "bge-reranker-v2-m3-free", RerankerAPIKey: "", MineruEndpointURL: "https://mineru.net", MineruAPIKey: "", MineruModelVersion: "vlm", MineruLanguage: "ch", MineruTimeout: 90 * time.Second, MaterialFulltextMaxBytes: 60 * 1024, MaterialRAGTopK: 8, AdminUsername: "admin", AdminPassword: "admin-secret-password-123", AuthRegistrationEnabled: true, AuthRequireApproval: false, MaxConcurrentAgents: 8, } phases = append(phases, struct { Layer string Snap map[string]string }{"default", snapshotFields(cfg)}) if path == "" { // 与 Compose 一致:根 .env(公共密钥)+ configs/.env.{ENV}(环境差异 overlay) _ = applyEnvFile(cfg, ".env") phases = append(phases, struct { Layer string Snap map[string]string }{"env-file:.env", snapshotFields(cfg)}) envName := cfg.Env if v := os.Getenv("ENV"); v != "" { envName = v } if envName == "" { envName = "dev" } overlay := filepath.Join("configs", ".env."+envName) _ = applyEnvFile(cfg, overlay) phases = append(phases, struct { Layer string Snap map[string]string }{"env-file:" + filepath.ToSlash(overlay), snapshotFields(cfg)}) } else { _ = applyEnvFile(cfg, path) phases = append(phases, struct { Layer string Snap map[string]string }{"env-file:" + filepath.ToSlash(path), snapshotFields(cfg)}) } // 允许系统环境变量覆盖 if v := os.Getenv("ENV"); v != "" { cfg.Env = v } if v := os.Getenv("DATA_DIR"); v != "" { cfg.DataDir = v } if v := os.Getenv("HTTP_HOST"); v != "" { cfg.HTTPHost = v } else if v := os.Getenv("APP_PUBLISH_HOST"); v != "" { cfg.HTTPHost = v } if v := os.Getenv("HTTP_PORT"); v != "" { if p, err := strconv.Atoi(v); err == nil { cfg.HTTPPort = p } } if v := os.Getenv("AGENT_BACKEND"); v != "" { cfg.AgentBackend = v } if v := os.Getenv("AGENT_TIMEOUT"); v != "" { if d, err := time.ParseDuration(v); err == nil { cfg.AgentTimeout = d } } if v := os.Getenv("AGENTX_API_BASE"); v != "" { cfg.AgentXAPIBase = v } else if v := os.Getenv("MINIMAX_ENDPOINT_URL"); v != "" { cfg.AgentXAPIBase = v } if v := os.Getenv("AGENTX_API_KEY"); v != "" { cfg.AgentXAPIKey = v } else if v := os.Getenv("MINIMAX_API_KEY"); v != "" { cfg.AgentXAPIKey = v } if v := os.Getenv("AGENTX_MODEL"); v != "" { cfg.AgentXModel = v } if v := os.Getenv("AGENTX_TIMEOUT"); v != "" { if d, err := time.ParseDuration(v); err == nil { cfg.AgentXTimeout = d } } if v := os.Getenv("WORKSPACE_TTL"); v != "" { if d, err := time.ParseDuration(v); err == nil { cfg.WorkspaceTTL = d } } if v := os.Getenv("WORKSPACE_QUOTA_BYTES"); v != "" { if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 { cfg.WorkspaceQuotaBytes = n } } if v := os.Getenv("PI_AGENT_BIN"); v != "" { cfg.PiAgentBin = v } if v := os.Getenv("PI_AGENT_BRIDGE_BIN"); v != "" { cfg.PiAgentBridgeBin = v } if v := os.Getenv("PI_AGENT_PROVIDER"); v != "" { cfg.PiAgentProvider = v } if v := os.Getenv("PI_AGENT_MODEL"); v != "" { cfg.PiAgentModel = v } if v := os.Getenv("KB_DB_HOST"); v != "" { cfg.KbDbHost = v } if v := os.Getenv("KB_DB_PORT"); v != "" { if p, err := strconv.Atoi(v); err == nil { cfg.KbDbPort = p } } if v := os.Getenv("KB_DB_USER"); v != "" { cfg.KbDbUser = v } if v := os.Getenv("KB_DB_PASSWORD"); v != "" { cfg.KbDbPassword = v } if v := os.Getenv("KB_DB_NAME"); v != "" { cfg.KbDbName = v } if v := os.Getenv("CONTEXT_DB_HOST"); v != "" { cfg.ContextDbHost = v } if v := os.Getenv("CONTEXT_DB_PORT"); v != "" { if p, err := strconv.Atoi(v); err == nil { cfg.ContextDbPort = p } } if v := os.Getenv("CONTEXT_DB_USER"); v != "" { cfg.ContextDbUser = v } if v := os.Getenv("CONTEXT_DB_PASSWORD"); v != "" { cfg.ContextDbPassword = v } if v := os.Getenv("CONTEXT_DB_NAME"); v != "" { cfg.ContextDbName = v } if v := os.Getenv("EMBEDDING_ENDPOINT_URL"); v != "" { cfg.EmbeddingEndpointURL = v } if v := os.Getenv("EMBEDDING_API_KEY"); v != "" { cfg.EmbeddingAPIKey = v } if v := os.Getenv("EMBEDDING_MODEL"); v != "" { cfg.EmbeddingModel = v } if v := os.Getenv("RERANKER_ENDPOINT_URL"); v != "" { cfg.RerankerEndpointURL = v } if v := os.Getenv("RERANKER_API_KEY"); v != "" { cfg.RerankerAPIKey = v } if v := os.Getenv("RERANKER_MODEL"); v != "" { cfg.RerankerModel = v } if v := os.Getenv("MINERU_ENDPOINT_URL"); v != "" { cfg.MineruEndpointURL = v } if v := os.Getenv("MINERU_API_KEY"); v != "" { cfg.MineruAPIKey = v } if v := os.Getenv("MINERU_MODEL_VERSION"); v != "" { cfg.MineruModelVersion = v } if v := os.Getenv("MINERU_LANGUAGE"); v != "" { cfg.MineruLanguage = v } if v := os.Getenv("MINERU_TIMEOUT"); v != "" { if d, err := time.ParseDuration(v); err == nil { cfg.MineruTimeout = d } } if v := os.Getenv("MATERIAL_FULLTEXT_MAX_BYTES"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { cfg.MaterialFulltextMaxBytes = n } } if v := os.Getenv("MATERIAL_RAG_TOP_K"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { cfg.MaterialRAGTopK = n } } if v := os.Getenv("ADMIN_USERNAME"); v != "" { cfg.AdminUsername = v } if v := os.Getenv("ADMIN_PASSWORD"); v != "" { cfg.AdminPassword = v } if v := os.Getenv("AUTH_REGISTRATION_ENABLED"); v != "" { cfg.AuthRegistrationEnabled = v == "true" || v == "1" } if v := os.Getenv("AUTH_REQUIRE_APPROVAL"); v != "" { cfg.AuthRequireApproval = v == "true" || v == "1" } if v := os.Getenv("MAX_CONCURRENT_AGENTS"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { cfg.MaxConcurrentAgents = n } } if v := os.Getenv("PI_AGENT_RUNNER_URL"); v != "" { cfg.PiAgentRunnerURL = v } if v := os.Getenv("PI_AGENT_RUNNER_AUTH_TOKEN"); v != "" { cfg.PiAgentRunnerAuthToken = v } if v := os.Getenv("RUNNER_AUTH_TOKEN"); v != "" { cfg.PiAgentRunnerAuthToken = v } if v := os.Getenv("JEV_DECIDE_MODE"); v != "" { cfg.JevDecideMode = strings.ToLower(strings.TrimSpace(v)) } if v := os.Getenv("JEV_BASE_URL"); v != "" { cfg.JevBaseURL = strings.TrimSpace(v) } if v := os.Getenv("JEV_API_KEY"); v != "" { cfg.JevAPIKey = strings.TrimSpace(v) } if v := os.Getenv("ALLOW_IN_MEMORY"); v != "" { cfg.AllowInMemory = v == "true" || v == "1" } if v := os.Getenv("CORS_ALLOWED_ORIGINS"); v != "" { cfg.CORSAllowedOrigins = parseCSVOrigins(v) } if v := os.Getenv("VAULT_STATE_ROOT"); v != "" { cfg.VaultStateRoot = v } if v := os.Getenv("VAULT_STORAGE_IDENTITY"); v != "" { cfg.VaultStorageIdentity = v } if v := os.Getenv("VAULT_MASTER_KEY"); v != "" { cfg.VaultMasterKey = v } if v := os.Getenv("FAV_DEGRADED_READ"); v != "" { cfg.FAVDegradedRead = v == "true" || v == "1" } if v := os.Getenv("FAV_PUBLISHER_ENABLED"); v != "" { cfg.FAVPublisherEnabled = v == "true" || v == "1" } if v := os.Getenv("HTTP_ADDR"); v != "" && strings.HasPrefix(v, ":") { if p, err := strconv.Atoi(strings.TrimPrefix(v, ":")); err == nil { cfg.HTTPPort = p cfg.HTTPHost = "0.0.0.0" } } applyDSNOverrides(cfg) applyCORSDefaults(cfg) NormalizeKBHostForRuntime(cfg) phases = append(phases, struct { Layer string Snap map[string]string }{"process-env", snapshotFields(cfg)}) // 显式 CLI 覆盖(第四层)——在实例设置之前生效。 if cliMutate != nil { cliMutate(cfg) phases = append(phases, struct { Layer string Snap map[string]string }{"cli", snapshotFields(cfg)}) } // 实例持久化部署设置(第五层,最终生效;SETTINGS_MODE=ignore 跳过)。 if !settingsModeIgnored() { is, err := loadInstanceSettings(DefaultConfigRoot()) if err != nil { return nil, nil, err } if err := applyInstanceSettings(cfg, is); err != nil { return nil, nil, err } phases = append(phases, struct { Layer string Snap map[string]string }{"instance-settings", snapshotFields(cfg)}) } if err := validateProductionSecrets(cfg); err != nil { return nil, nil, err } if err := ValidateIndependentVault(cfg.VaultStateRoot, cfg.DataDir); err != nil { return nil, nil, err } return cfg, diffLayers(phases), nil } // ApplyCLIOverrides 在 Load 之后应用显式 CLI 覆盖并更新字段来源层。 // 返回应用后的配置副本来源表(不修改原 cfg)。 func ApplyCLIOverrides(cfg *Config, src map[string]FieldSource, mutate func(*Config)) (map[string]FieldSource, error) { if cfg == nil || mutate == nil { return src, nil } clone := *cfg before := snapshotFields(cfg) mutate(&clone) after := snapshotFields(&clone) out := make(map[string]FieldSource, len(src)) for k, v := range src { out[k] = v } for field, val := range after { if before[field] != val { fs := FieldSource{Field: field, Layer: "cli", Set: true} out[field] = fs } } return out, 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 "AGENTX_API_BASE", "MINIMAX_ENDPOINT_URL": cfg.AgentXAPIBase = val case "AGENTX_API_KEY", "MINIMAX_API_KEY": cfg.AgentXAPIKey = val case "AGENTX_MODEL": cfg.AgentXModel = val case "AGENTX_TIMEOUT": if d, err := time.ParseDuration(val); err == nil { cfg.AgentXTimeout = d } case "WORKSPACE_TTL": if d, err := time.ParseDuration(val); err == nil { cfg.WorkspaceTTL = d } case "WORKSPACE_QUOTA_BYTES": if n, err := strconv.ParseInt(val, 10, 64); err == nil && n > 0 { cfg.WorkspaceQuotaBytes = n } 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 "MINERU_ENDPOINT_URL": cfg.MineruEndpointURL = val case "MINERU_API_KEY": cfg.MineruAPIKey = val case "MINERU_MODEL_VERSION": cfg.MineruModelVersion = val case "MINERU_LANGUAGE": cfg.MineruLanguage = val case "MINERU_TIMEOUT": if d, err := time.ParseDuration(val); err == nil { cfg.MineruTimeout = d } case "MATERIAL_FULLTEXT_MAX_BYTES": if n, err := strconv.Atoi(val); err == nil && n > 0 { cfg.MaterialFulltextMaxBytes = n } case "MATERIAL_RAG_TOP_K": if n, err := strconv.Atoi(val); err == nil && n > 0 { cfg.MaterialRAGTopK = n } 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 { if strings.TrimSpace(root) == "" { return nil } cr := filepath.Clean(root) for _, env := range []string{"dev", "test", "main"} { if strings.HasSuffix(cr, filepath.Join("data", env)) { return fmt.Errorf("config: VAULT_STATE_ROOT must not be runtime data root %s", root) } } if dataDir != "" { cd := filepath.Clean(dataDir) if cr == cd { return fmt.Errorf("config: VAULT_STATE_ROOT must be independent from DATA_DIR") } } return nil } func parseCSVOrigins(raw string) []string { parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out } func applyCORSDefaults(cfg *Config) { if cfg == nil || len(cfg.CORSAllowedOrigins) > 0 { return } if cfg.Env == "dev" || cfg.Env == "test" { cfg.CORSAllowedOrigins = []string{ "http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:8080", "http://127.0.0.1:8080", "http://localhost:8081", "http://127.0.0.1:8081", } } } func validateProductionSecrets(cfg *Config) error { if cfg == nil || cfg.Env == "" || cfg.Env == "dev" || cfg.Env == "test" { return nil } weak := map[string]string{ "KB_DB_PASSWORD": cfg.KbDbPassword, "CONTEXT_DB_PASSWORD": cfg.ContextDbPassword, "ADMIN_PASSWORD": cfg.AdminPassword, } defaults := []string{"shared_kb_secret_pass", "dev_secret_db_pass", "prod_highly_secure_db_pass", "admin-secret-password-123"} for envKey, val := range weak { if strings.TrimSpace(val) == "" { return fmt.Errorf("%s must be set in %s environment", envKey, cfg.Env) } for _, d := range defaults { if val == d { return fmt.Errorf("%s uses insecure default value in %s environment", envKey, cfg.Env) } } } if cfg.AllowInMemory { return fmt.Errorf("ALLOW_IN_MEMORY must be false in %s environment", cfg.Env) } if strings.TrimSpace(cfg.PiAgentRunnerURL) != "" && strings.TrimSpace(cfg.PiAgentRunnerAuthToken) == "" { return fmt.Errorf("PI_AGENT_RUNNER_AUTH_TOKEN (or RUNNER_AUTH_TOKEN) must be set when PI_AGENT_RUNNER_URL is configured in %s environment", cfg.Env) } return nil } func applyDSNOverrides(cfg *Config) { if dsn := os.Getenv("APP_DB_DSN"); dsn != "" { if host, port, user, password, name, ok := parsePostgresDSN(dsn); ok { cfg.ContextDbHost = host cfg.ContextDbPort = port cfg.ContextDbUser = user cfg.ContextDbPassword = password cfg.ContextDbName = name } } if dsn := os.Getenv("POSTGRES_SHARED_KB_DSN"); dsn != "" { if host, port, user, password, name, ok := parsePostgresDSN(dsn); ok { cfg.KbDbHost = host cfg.KbDbPort = port cfg.KbDbUser = user cfg.KbDbPassword = password cfg.KbDbName = name } } } func parsePostgresDSN(dsn string) (host string, port int, user, password, dbName string, ok bool) { if !strings.HasPrefix(dsn, "postgres://") { return "", 0, "", "", "", false } rest := strings.TrimPrefix(dsn, "postgres://") at := strings.LastIndex(rest, "@") if at <= 0 { return "", 0, "", "", "", false } creds := rest[:at] hostPart := rest[at+1:] slash := strings.Index(hostPart, "/") if slash <= 0 { return "", 0, "", "", "", false } hostPort := hostPart[:slash] dbName = strings.SplitN(hostPart[slash+1:], "?", 2)[0] userPass := strings.SplitN(creds, ":", 2) if len(userPass) != 2 { return "", 0, "", "", "", false } user = userPass[0] password = userPass[1] hostPortParts := strings.SplitN(hostPort, ":", 2) host = hostPortParts[0] port = 5432 if len(hostPortParts) == 2 { if p, err := strconv.Atoi(hostPortParts[1]); err == nil { port = p } } return host, port, user, password, dbName, true } // 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" } // Check 返回配置问题列表(空 = 通过)。只读、无副作用,供 config check // 子命令与部署预检使用;完整分层溯源(explain)随 U10b ConfigSnapshot 提供。 func (c *Config) Check() []string { var issues []string if c == nil { return []string{"config is nil"} } if err := validateProductionSecrets(c); err != nil { issues = append(issues, err.Error()) } if c.HTTPPort <= 0 || c.HTTPPort > 65535 { issues = append(issues, fmt.Sprintf("http port out of range: %d", c.HTTPPort)) } if strings.TrimSpace(c.DataDir) == "" { issues = append(issues, "data dir is empty") } return issues } // MaskedSummary 输出脱敏的关键配置摘要(不含任何凭证值)。 func (c *Config) MaskedSummary() string { mask := func(s string) string { if strings.TrimSpace(s) == "" { return "" } return "" } return fmt.Sprintf("env=%s data_dir=%s listen=%s:%d backend=%s timeout=%s\n"+ "app_db=%s:%d/%s user=%s password=%s\n"+ "kb_db=%s:%d/%s user=%s password=%s\n"+ "runner_url=%s runner_token=%s agentx_key=%s allow_in_memory=%v", c.Env, c.DataDir, c.HTTPHost, c.HTTPPort, c.AgentBackend, c.AgentTimeout, c.ContextDbHost, c.ContextDbPort, c.ContextDbName, c.ContextDbUser, mask(c.ContextDbPassword), c.KbDbHost, c.KbDbPort, c.KbDbName, c.KbDbUser, mask(c.KbDbPassword), c.PiAgentRunnerURL, mask(c.PiAgentRunnerAuthToken), mask(c.AgentXAPIKey), c.AllowInMemory) }