- <config_root>/instance-settings.json is the final config layer: default > env-file(s) > process-env > cli > instance-settings - LoadForServe wires the CLI mutation hook BEFORE instance settings; serve/check/explain all use it; plain Load keeps legacy behavior - bootstrap denylist (env) fails closed (no self-locating file loop); SETTINGS_MODE=ignore skips the layer as the recovery path - reflection-based typed application: string/int/bool/duration(string or seconds)/string-array; unknown fields and type mismatches fail closed - group/world-writable settings file rejected; absent key = inherit - sources table gains the instance-settings layer; e2e smoke verified (override applies; ignore-mode reverts to default)
865 lines
27 KiB
Go
865 lines
27 KiB
Go
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 "<unset>"
|
||
}
|
||
return "<set>"
|
||
}
|
||
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)
|
||
}
|