diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 6ef60917..08a69510 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,7 +5,7 @@ ## 焦点 -1. **系统体检整改**:Wave 0(SSE/缓存 P0)**done**;Wave 1(main ENV E-1)待决策 +1. **系统体检整改**:Wave 0 **done**;Wave 1(main ENV E-1/E-2/E-5)**in_progress** 2. **rdev 模板深度 / document-author-only**:已合入 `origin/dev`;未晋升 test/main 3. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读 @@ -13,6 +13,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor | 2026-09-18-wave1-main-env-fix | fix/wave1-main-env-fix | - | infra | in_progress | | Cursor | 2026-09-18-wave0-p0-bug-fixes | fix/wave0-p0-sse-chat-cache-bugs | - | chat + web | done | | Kimi | 2026-09-17-system-health-check(只读审计,未实施) | - | - | audit | done | | Cursor | 2026-09-17-docgov-modification-proposal-http | feature/docgov-modification-proposal-http | - | docgov | done | @@ -132,7 +133,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | 2026-09-18 | Wave 0 P0 已合入并冒烟 PASS(见 [test-report](../test-reports/2026-09-18-wave0-p0-bug-fixes-test-report.md)) | - | +| Cursor | merging | 2026-09-18 | Wave 1 main ENV(fix/wave1-main-env-fix) | - | **phase**: `idle` | `merging` | `smoke` | `e2e` diff --git a/.doc/ops/short-term/2026-09-18-wave1-main-env-fix.md b/.doc/ops/short-term/2026-09-18-wave1-main-env-fix.md new file mode 100644 index 00000000..ea4ed473 --- /dev/null +++ b/.doc/ops/short-term/2026-09-18-wave1-main-env-fix.md @@ -0,0 +1,52 @@ +--- +title: "Wave 1:main 环境 ENV/schema 修正(E-1/E-2/E-5)" +status: in_progress +created: 2026-09-18 +updated: 2026-09-18 +owner: Cursor +branch: fix/wave1-main-env-fix +worktree: "-" +mutex: infra +--- + +# Wave 1:main 环境 ENV/schema 修正(E-1/E-2/E-5) + +## 目标 + +统一 main 环境 app/events 的 `ENV=main` 语义,用 compose 依赖强制 events 先迁移再启 app,并关闭 `Env==""` 视同 dev 的导入门禁漏洞。 + +## 范围 + +- `docker-compose.main.yml`:app `ENV: main`;`depends_on.events` healthy +- `configs/.env.main` / `.env.main.example`:`ENV=main` +- `internal/server/chat_window_api.go`:去掉空 Env 视同 dev +- 回归测试 + 真实 main rebuild 验证 + +## 非目标 + +- Wave 2+;不改 `event_consumer_modes` 的 mode 值;不触碰 FAV / MULTI_INTENT / SEMANTIC_SUMMARY_REPLACE + +## 步骤 + +1. [x] 从本地 `dev` 开分支并 CURRENT 认领 +2. [ ] E-1 ENV 对齐 +3. [ ] E-2 depends_on events +4. [ ] E-5 空 Env fail-closed +5. [ ] 单测 + 合入 + main rebuild 冒烟 + +## 验收标准 + +- [ ] app/events health 均报 `env=main` +- [ ] main `schema_version=49` +- [ ] `event_consumer_modes` 中 main 四行仍为 `off` +- [ ] `go test -short` 全绿;导入端点空 Env → 403 + +## 相关链接 + +- Audit: [../../audit/2026-09-17-system-health-check.md](../../audit/2026-09-17-system-health-check.md) +- Remediation: [../../remediation/2026-09-17-system-health-remediation-plan.md](../../remediation/2026-09-17-system-health-remediation-plan.md) +- Test report: (待归档) + +## 证据 / 备注 + +(实施中) diff --git a/configs/.env.main.example b/configs/.env.main.example index 335b4755..8f5139d7 100644 --- a/configs/.env.main.example +++ b/configs/.env.main.example @@ -4,7 +4,7 @@ # 根 .env 提供公共模型与检索配置;本文件覆盖生产端口、数据库和生产密钥。 # ============================================================================= -ENV=prod +ENV=main # 1. 业务 HTTP 服务 HTTP_PORT=8000 diff --git a/docker-compose.main.yml b/docker-compose.main.yml index 7a9dd74d..2697463c 100644 --- a/docker-compose.main.yml +++ b/docker-compose.main.yml @@ -86,8 +86,10 @@ services: depends_on: system-db: condition: service_healthy + events: + condition: service_healthy environment: - ENV: prod + ENV: main HTTP_ADDR: ":${HTTP_PORT:-8000}" HTTP_PORT: "${HTTP_PORT:-8000}" HTTP_HOST: "0.0.0.0" diff --git a/internal/config/compose_env_consistency_test.go b/internal/config/compose_env_consistency_test.go new file mode 100644 index 00000000..2189b081 --- /dev/null +++ b/internal/config/compose_env_consistency_test.go @@ -0,0 +1,110 @@ +package config_test + +import ( + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" +) + +func composeRepoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller") + } + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) +} + +var composeServiceENV = regexp.MustCompile(`(?m)^\s+ENV:\s*(\S+)\s*$`) + +func extractServiceENV(composeYAML, serviceName string) (string, error) { + // Find " serviceName:" at top-level service indentation (two spaces). + marker := "\n " + serviceName + ":\n" + idx := strings.Index(composeYAML, marker) + if idx < 0 { + // file may start with the service after "services:\n" + alt := "\nservices:\n " + serviceName + ":\n" + idx = strings.Index(composeYAML, alt) + if idx < 0 { + return "", os.ErrNotExist + } + idx += len("\nservices:") + } + rest := composeYAML[idx+1:] // start at " serviceName:" + // Next top-level service starts at "\n :" where name is not indented further. + next := regexp.MustCompile(`(?m)^ [a-zA-Z0-9_-]+:\s*$`) + locs := next.FindAllStringIndex(rest, -1) + block := rest + if len(locs) >= 2 { + // locs[0] is current service header; locs[1] is next sibling + block = rest[:locs[1][0]] + } + m := composeServiceENV.FindStringSubmatch(block) + if m == nil { + return "", os.ErrNotExist + } + return strings.Trim(m[1], `"'`), nil +} + +func TestComposeAppEventsENVAligned(t *testing.T) { + root := composeRepoRoot(t) + files := []string{ + "docker-compose.dev.yml", + "docker-compose.test.yml", + "docker-compose.main.yml", + } + for _, name := range files { + raw, err := os.ReadFile(filepath.Join(root, name)) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + text := string(raw) + appEnv, err := extractServiceENV(text, "app") + if err != nil { + t.Fatalf("%s: app ENV: %v", name, err) + } + eventsEnv, err := extractServiceENV(text, "events") + if err != nil { + t.Fatalf("%s: events ENV: %v", name, err) + } + if appEnv != eventsEnv { + t.Fatalf("%s: app ENV=%q != events ENV=%q", name, appEnv, eventsEnv) + } + } +} + +func TestComposeMainAppDependsOnEventsHealthy(t *testing.T) { + root := composeRepoRoot(t) + raw, err := os.ReadFile(filepath.Join(root, "docker-compose.main.yml")) + if err != nil { + t.Fatal(err) + } + text := string(raw) + marker := "\n app:\n" + idx := strings.Index(text, marker) + if idx < 0 { + t.Fatal("app service not found in docker-compose.main.yml") + } + rest := text[idx+1:] + next := regexp.MustCompile(`(?m)^ [a-zA-Z0-9_-]+:\s*$`) + locs := next.FindAllStringIndex(rest, -1) + block := rest + if len(locs) >= 2 { + block = rest[:locs[1][0]] + } + // Nail E-2: app depends_on events with service_healthy + re := regexp.MustCompile(`(?m)^\s+events:\s*\n\s+condition:\s*service_healthy\s*$`) + if !re.MatchString(block) { + t.Fatalf("docker-compose.main.yml app must depends_on events with condition: service_healthy\n--- app block ---\n%s", block) + } + appEnv, err := extractServiceENV(text, "app") + if err != nil { + t.Fatal(err) + } + if appEnv != "main" { + t.Fatalf("main app ENV want main, got %q", appEnv) + } +} diff --git a/internal/server/chat_window_api.go b/internal/server/chat_window_api.go index e2c67d25..f279a7da 100644 --- a/internal/server/chat_window_api.go +++ b/internal/server/chat_window_api.go @@ -167,8 +167,8 @@ func (s *Server) handleImportChatMessages(w http.ResponseWriter, r *http.Request return } - // 安全门禁:生产环境默认禁止调用,仅在 dev 或显式开启时开放 - isDev := s.cfg.Env == "dev" || s.cfg.Env == "development" || s.cfg.Env == "" || os.Getenv("ENABLE_DEV_IMPORT") == "true" + // 安全门禁:生产环境默认禁止调用,仅在显式 dev 或 ENABLE_DEV_IMPORT 时开放 + isDev := s.cfg.Env == "dev" || s.cfg.Env == "development" || os.Getenv("ENABLE_DEV_IMPORT") == "true" if !isDev { writeSafeError(w, r, http.StatusForbidden, "chat messages import endpoint is only available in dev/demo environment") return diff --git a/internal/server/chat_window_api_test.go b/internal/server/chat_window_api_test.go index 42316542..954695f0 100644 --- a/internal/server/chat_window_api_test.go +++ b/internal/server/chat_window_api_test.go @@ -179,3 +179,30 @@ func TestServer_ChatWindow_ImportGuard(t *testing.T) { t.Fatalf("expected 403 Forbidden in production env, got %d", rrImport.Code) } } + +func TestServer_ChatWindow_ImportGuard_EmptyEnvFailsClosed(t *testing.T) { + // E-5:Env 为空不得视同 dev,导入端点默认拒绝 + cfg := &config.Config{ + Env: "test", + HTTPHost: "127.0.0.1", + HTTPPort: 8889, + DataDir: t.TempDir(), + AllowInMemory: true, + AgentBackend: "stub", + } + svc := bizdev.MustNewService(t, cfg) + srv := server.NewServer(cfg, svc) + cfg.Env = "" + t.Setenv("ENABLE_DEV_IMPORT", "") + handler := srv.Routes() + + reqImport := httptest.NewRequest("POST", "/api/v1/chat/messages/import", bytes.NewReader([]byte("[]"))) + reqImport.Header.Set("Content-Type", "application/json") + withBearerAuth(reqImport, loginTestAdmin(t, handler, cfg)) + rrImport := httptest.NewRecorder() + handler.ServeHTTP(rrImport, reqImport) + + if rrImport.Code != http.StatusForbidden { + t.Fatalf("expected 403 Forbidden when Env is empty, got %d body=%s", rrImport.Code, rrImport.Body.String()) + } +}