diff --git a/configs/.env.dev.example b/configs/.env.dev.example index ecc5c863..1ac7fffa 100644 --- a/configs/.env.dev.example +++ b/configs/.env.dev.example @@ -21,6 +21,8 @@ SYSTEM_DB_NAME=agent_system_dev SYSTEM_DB_PUBLISH_PORT=5433 # 4. 共享知识库(容器 app 经 shuimu-kb-shared 访问) +# KB_DB_PASSWORD 必须在仓库根 .env 提供;config.Load("") 会分层合并根 .env + 本文件。 +# 勿依赖代码内默认口令(已清空);宿主机 go test / 本地进程同样走分层 Load。 KB_DB_USER=regkb KB_DB_NAME=regulations KB_DB_HOST=shuimu-kb-shared diff --git a/configs/.env.main.example b/configs/.env.main.example index 63b0aa53..335b4755 100644 --- a/configs/.env.main.example +++ b/configs/.env.main.example @@ -22,10 +22,12 @@ SYSTEM_DB_NAME=agent_system_main SYSTEM_DB_PUBLISH_PORT=5435 # 4. 共享知识库连接 (只读检索共享数据) +# KB_DB_PASSWORD 必须在仓库根 .env;Load("") 分层合并;生产禁止弱口令/空口令。 KB_DB_USER=regkb KB_DB_NAME=regulations KB_DB_HOST=shuimu-kb-shared KB_DB_PORT=5432 +KB_DB_PASSWORD= # 5. Cursor / LLM Agent Runner 服务 CURSOR_RUNNER_PORT=18767 diff --git a/configs/.env.test.example b/configs/.env.test.example index 7ac78432..a4e712cb 100644 --- a/configs/.env.test.example +++ b/configs/.env.test.example @@ -20,6 +20,7 @@ SYSTEM_DB_NAME=agent_system_test SYSTEM_DB_PUBLISH_PORT=5434 # 4. 共享知识库连接 (只读检索共享数据) +# KB_DB_PASSWORD 必须在仓库根 .env;config.Load("") 分层合并根 .env + 本文件。勿依赖代码默认口令。 KB_DB_USER=regkb KB_DB_NAME=regulations KB_DB_HOST=shuimu-kb-shared diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 8561bb70..130846a6 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -30,6 +30,7 @@ type HealthResponse struct { AgentBackend string `json:"agent_backend"` Version string `json:"version"` SharedKBPort int `json:"shared_kb_port"` + KBReady bool `json:"kb_ready"` DatabaseReady bool `json:"database_ready"` SchemaVersion int64 `json:"schema_version,omitempty"` SchemaReady bool `json:"schema_ready,omitempty"` @@ -117,12 +118,15 @@ func (s *Server) buildHealthResponse(strict bool) (HealthResponse, int) { } } + kbReady := s.kb != nil && s.kb.Online() + return HealthResponse{ Status: status, Env: s.cfg.Env, AgentBackend: s.cfg.AgentBackend, Version: "v1.0", SharedKBPort: s.cfg.KbDbPort, + KBReady: kbReady, DatabaseReady: dbReady, SchemaVersion: schemaVersion, SchemaReady: schemaReady, diff --git a/internal/server/piagent_e2e_test.go b/internal/server/piagent_e2e_test.go index 232fd059..b4e3cf6f 100644 --- a/internal/server/piagent_e2e_test.go +++ b/internal/server/piagent_e2e_test.go @@ -9,7 +9,6 @@ import ( "net/http/httptest" "os" "path/filepath" - "strings" "testing" "time" @@ -22,8 +21,13 @@ import ( "chat-agent-system/internal/squad" ) -// mergePiAgentE2EKbConfig 从 .env / 环境变量注入共享知识库与向量检索配置(E2E 必须走真实 KB,不可 stub)。 +// mergePiAgentE2EKbConfig 从分层 Load(根 .env + configs/.env.{ENV})注入共享知识库配置。 +// E2E 默认 fail-closed:须在线 KB;仅 KB_ALLOW_OFFLINE=1 时允许降级并打印 NOTE。 func mergePiAgentE2EKbConfig(cfg *config.Config) { + if cfg == nil { + return + } + _ = os.Setenv("KB_REQUIRE_ONLINE", "1") envCfg, err := config.Load("") if err == nil && envCfg != nil { cfg.KbDbHost = envCfg.KbDbHost @@ -38,39 +42,16 @@ func mergePiAgentE2EKbConfig(cfg *config.Config) { cfg.RerankerAPIKey = envCfg.RerankerAPIKey cfg.RerankerModel = envCfg.RerankerModel } + config.NormalizeKBHostForRuntime(cfg) if cfg.KbDbHost == "" { cfg.KbDbHost = "127.0.0.1" } - // Host-side go test cannot use compose DNS; shuimu-kb-shared often resolves to a - // poisoned/VPN address (e.g. 198.18.0.0/15). Prefer the published localhost port. - if cfg.KbDbHost == "shuimu-kb-shared" || cfg.KbDbHost == "shuimu-kb" { - cfg.KbDbHost = "127.0.0.1" - } if cfg.KbDbPort == 0 { cfg.KbDbPort = 5432 } if cfg.KbDbUser == "" { cfg.KbDbUser = "regkb" } - // configs/.env.dev often omits KB_DB_PASSWORD; Load then keeps the insecure default. - // Prefer process env, then root .env, so host E2E actually authenticates to :5432. - if v := strings.TrimSpace(os.Getenv("KB_DB_PASSWORD")); v != "" { - cfg.KbDbPassword = v - } else if cfg.KbDbPassword == "" || cfg.KbDbPassword == "shared_kb_secret_pass" { - if b, err := os.ReadFile(".env"); err == nil { - for _, line := range strings.Split(string(b), "\n") { - line = strings.TrimSpace(line) - if !strings.HasPrefix(line, "KB_DB_PASSWORD=") { - continue - } - cfg.KbDbPassword = strings.Trim(strings.TrimPrefix(line, "KB_DB_PASSWORD="), "\"'") - break - } - } - } - if cfg.KbDbPassword == "" { - cfg.KbDbPassword = envOr("KB_DB_PASSWORD", "kb_secret_pass") - } if cfg.KbDbName == "" { cfg.KbDbName = "regulations" } @@ -131,12 +112,20 @@ func TestPiAgent_FullLifecycle_MultiRole_RealWorld_E2E(t *testing.T) { var healthData server.HealthResponse _ = json.NewDecoder(respHealth.Body).Decode(&healthData) respHealth.Body.Close() - t.Logf("✓ [0. 环境校验] Health检查正常: Env=%s, AgentBackend=%s, SharedKBPort=%d", - healthData.Env, healthData.AgentBackend, healthData.SharedKBPort) + t.Logf("✓ [0. 环境校验] Health检查正常: Env=%s, AgentBackend=%s, SharedKBPort=%d kb_ready=%v", + healthData.Env, healthData.AgentBackend, healthData.SharedKBPort, healthData.KBReady) + if !kb.RequireOnline() && !healthData.KBReady { + t.Logf("NOTE: KB offline degradation test (KB_ALLOW_OFFLINE=1)") + } kbClient := kb.NewClient(cfg) defer kbClient.Close() - if stats, err := kbClient.Stats(ctx); err != nil { - t.Logf("⚠ [0. 知识库] 未连接共享 KB(%s:%d/%s): %v", cfg.KbDbHost, cfg.KbDbPort, cfg.KbDbName, err) + if !kbClient.Online() { + if kb.RequireOnline() { + t.Fatalf("KB required online but offline (host=%s port=%d); set KB_ALLOW_OFFLINE=1 only for degradation tests", cfg.KbDbHost, cfg.KbDbPort) + } + t.Logf("NOTE: KB offline degradation test — continuing without shared KB") + } else if stats, err := kbClient.Stats(ctx); err != nil { + t.Fatalf("KB online but Stats failed: %v", err) } else if reg, ok := stats[kb.DomainRegulatory]; ok { t.Logf("✓ [0. 知识库] 已连接共享 KB: documents=%d chunks=%d", reg.DocumentCount, reg.ChunkCount) } else { diff --git a/scripts/run-nightly-real-pi.sh b/scripts/run-nightly-real-pi.sh index 19d5a119..663fa883 100755 --- a/scripts/run-nightly-real-pi.sh +++ b/scripts/run-nightly-real-pi.sh @@ -126,6 +126,44 @@ export PI_AGENT_RUNNER_AUTH_TOKEN="${TOKEN}" export RUNNER_AUTH_TOKEN="${TOKEN}" export PI_AGENT_MODEL="${MODEL_ID}" export RUN_PI_E2E=1 +export KB_REQUIRE_ONLINE="${KB_REQUIRE_ONLINE:-1}" + +# Shared KB preflight (host :5432). Fail closed unless KB_ALLOW_OFFLINE=1. +preflight_shared_kb() { + if [[ "${KB_ALLOW_OFFLINE:-0}" == "1" ]]; then + echo "NOTE: KB offline degradation test (KB_ALLOW_OFFLINE=1)" | tee "${EVIDENCE_DIR}/kb-allow-offline.txt" + return 0 + fi + local kb_host="${KB_DB_HOST:-127.0.0.1}" + local kb_port="${KB_DB_PORT:-5432}" + local kb_user="${KB_DB_USER:-regkb}" + local kb_name="${KB_DB_NAME:-regulations}" + local kb_pass="${KB_DB_PASSWORD:-}" + if [[ -z "${kb_pass}" && -f "${ROOT}/.env" ]]; then + kb_pass="$(grep -E '^KB_DB_PASSWORD=' "${ROOT}/.env" | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'")" + fi + if [[ -z "${kb_pass}" ]]; then + echo "KB_DB_PASSWORD missing (root .env or env); cannot preflight shared KB" | tee "${EVIDENCE_DIR}/infra-kb-missing-password.txt" + write_receipt infra_fail infra "${EVIDENCE_DIR}/infra-kb-missing-password.txt" + echo "infrastructure failure: shared KB password missing" >&2 + exit 1 + fi + if ! command -v psql >/dev/null 2>&1; then + echo "psql not found; skip SQL preflight, rely on go test KB_REQUIRE_ONLINE" | tee "${EVIDENCE_DIR}/kb-preflight-skipped-no-psql.txt" + return 0 + fi + if ! PGPASSWORD="${kb_pass}" psql -h "${kb_host}" -p "${kb_port}" -U "${kb_user}" -d "${kb_name}" -c 'SELECT 1' >/dev/null 2>"${EVIDENCE_DIR}/kb-preflight.err"; then + { + echo "shared KB preflight failed host=${kb_host} port=${kb_port} db=${kb_name} user=${kb_user}" + cat "${EVIDENCE_DIR}/kb-preflight.err" 2>/dev/null || true + } | tee "${EVIDENCE_DIR}/infra-kb-down.txt" + write_receipt infra_fail infra "${EVIDENCE_DIR}/infra-kb-down.txt" + echo "infrastructure failure: shared KB unreachable" >&2 + exit 1 + fi + echo "shared KB preflight OK ${kb_host}:${kb_port}/${kb_name}" | tee "${EVIDENCE_DIR}/kb-preflight-ok.txt" +} +preflight_shared_kb # Clear env pollution that flakes short/config tests elsewhere. unset AGENT_TIMEOUT KB_DATABASE_URL KB_EMBEDDING_DIM 2>/dev/null || true