Files
Agent-System/internal/config/config.go
T
agent-bot 2ad1ef393a
CI / go-vet (pull_request) Successful in 14s
CI / short-tests-report (pull_request) Failing after 36s
CI / backup-restore-drill (pull_request) Successful in 27s
CI / web (pull_request) Successful in 32s
feat(bootstrap): instance identity, single-start lock, root checks (U10c slice 1)
- internal/bootstrap.EnsureInstance on the serve path: normalized writable
  roots, symlink-aware validation, data-root vs vault containment rejection,
  filesystem-root refusal
- instance-marker.json in data root: first boot creates; foreign instance
  ID fails closed (explicit adopt/migrate required, KTD18)
- instance.lock non-blocking flock detects duplicate starts locally without
  any governance tooling; ownership held until process exit
- Config.InstanceID (INSTANCE_ID env/file key; default 'default') joins the
  instance-settings bootstrap denylist (identity cannot be self-overwritten)
- auth is Bearer+localStorage per origin (no cookies) - browser isolation
  holds across ports; documented in the unit template
- deploy/systemd/agent-system@.service template (instance %i: env file,
  DATA_DIR, hardened service scope)
- tests: marker create/foreign reject, vault overlap, invalid IDs, root
  refusal, in-process duplicate-start detection
2026-09-29 08:32:15 +00:00

872 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type Config struct {
Env string `json:"env"`
InstanceID string `json:"instance_id"`
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",
InstanceID: "default",
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("INSTANCE_ID"); v != "" {
cfg.InstanceID = 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 "INSTANCE_ID":
cfg.InstanceID = 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)
}