From 426af51365b7d0e58749eb0649ea166a5f52d23a Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 18 Sep 2026 03:39:08 +0000 Subject: [PATCH 01/16] =?UTF-8?q?fix(ctxmgr):=20EnsureChatSession=20?= =?UTF-8?q?=E5=BD=92=E5=B1=9E=20COALESCE=20=E6=94=B9=E4=B8=BA=E5=B7=B2?= =?UTF-8?q?=E6=9C=89=E5=80=BC=E4=BC=98=E5=85=88=EF=BC=88R-1c=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 防止跨用户通过 upsert/消息落库覆盖已绑定的 chat_sessions.user_id;新增 EnsureChatSessionOwned。 Co-authored-by: Cursor --- internal/ctxmgr/chat_history.go | 20 ++++++++++++++++++-- internal/ctxmgr/manager.go | 4 ++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/internal/ctxmgr/chat_history.go b/internal/ctxmgr/chat_history.go index 253ed6ef..54a189e1 100644 --- a/internal/ctxmgr/chat_history.go +++ b/internal/ctxmgr/chat_history.go @@ -32,7 +32,7 @@ func (m *ContextManager) EnsureChatSession(ctx context.Context, sessionID string VALUES ($1, $2, $3, $4, now(), now()) ON CONFLICT (session_id) DO UPDATE SET title = COALESCE(NULLIF($4, ''), chat_sessions.title), - user_id = COALESCE($2, chat_sessions.user_id), + user_id = COALESCE(chat_sessions.user_id, $2), project_id = COALESCE($3, chat_sessions.project_id), updated_at = now() `, sessionID, uid, pid, title) @@ -79,7 +79,7 @@ func (m *ContextManager) EnsureChatSession(ctx context.Context, sessionID string if title != "" { sess.Title = title } - if userID > 0 { + if userID > 0 && sess.UserID <= 0 { sess.UserID = userID } if projectID > 0 { @@ -97,6 +97,22 @@ func (m *ContextManager) EnsureChatSession(ctx context.Context, sessionID string return ©, nil } +// ErrSessionOwnerMismatch 表示调用者试图操作已绑定给其他用户的会话。 +var ErrSessionOwnerMismatch = errors.New("ctxmgr: session owner mismatch") + +// EnsureChatSessionOwned 在写入前校验归属:已绑定且不匹配调用者时返回 +// ErrSessionOwnerMismatch;未绑定或本就是调用者本人时正常落地。 +func (m *ContextManager) EnsureChatSessionOwned( + ctx context.Context, sessionID string, callerUserID, projectID int64, title string, +) (*ChatSessionSummary, error) { + if callerUserID > 0 { + if owner, err := m.GetSessionOwnerID(ctx, sessionID); err == nil && owner > 0 && owner != callerUserID { + return nil, ErrSessionOwnerMismatch + } + } + return m.EnsureChatSession(ctx, sessionID, callerUserID, projectID, title) +} + // ListChatSessions 分页查询历史会话列表 func (m *ContextManager) ListChatSessions(ctx context.Context, filter SessionFilter) ([]ChatSessionSummary, error) { if filter.Limit <= 0 { diff --git a/internal/ctxmgr/manager.go b/internal/ctxmgr/manager.go index 430e60e5..80a80ba4 100644 --- a/internal/ctxmgr/manager.go +++ b/internal/ctxmgr/manager.go @@ -190,13 +190,13 @@ func (m *ContextManager) RecordUserMessageWithID(ctx context.Context, sessionID } } - // 确保会话存在 + // 确保会话存在(user_id 已有值优先,防止跨用户覆盖归属) _, _ = m.pool.Exec(ctx, ` INSERT INTO chat_sessions (session_id, user_id, project_id, title, created_at, updated_at) VALUES ($1, $2, $3, '对话协同会话', now(), now()) ON CONFLICT (session_id) DO UPDATE SET updated_at = now(), - user_id = COALESCE($2, chat_sessions.user_id), + user_id = COALESCE(chat_sessions.user_id, $2), project_id = COALESCE($3, chat_sessions.project_id) `, sessionID, uid, pid) From ecdf1fc833991c6ef38faf4eaf667a867b4a4130 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 18 Sep 2026 03:39:08 +0000 Subject: [PATCH 02/16] =?UTF-8?q?fix(server):=20getOrCreateSession=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BC=9A=E8=AF=9D=E5=BD=92=E5=B1=9E=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=EF=BC=88R-1a=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 调用方传入 callerUserID;持久层与内存会话归属不匹配时返回 SESSION_FORBIDDEN;覆盖 NewSession 默认 UserID=1 占位。 Co-authored-by: Cursor --- internal/server/handlers.go | 33 ++++++++++++++++++++++++++++----- internal/server/server.go | 29 ++++++++++++++++++++++++----- internal/server/sse.go | 16 +++++++++++++--- 3 files changed, 65 insertions(+), 13 deletions(-) diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 95b1c663..54757d08 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -143,8 +143,8 @@ func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) { return } - sess := s.getOrCreateSession(sessionID, "", "", "", "", "", "", "", "") - if sess == nil || sess.UserCtx == nil { + sess, err := s.getOrCreateSession(0, sessionID, "", "", "", "", "", "", "", "") + if err != nil || sess == nil || sess.UserCtx == nil { writeSafeError(w, r, http.StatusNotFound, "session or project context not found") return } @@ -169,7 +169,8 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { } req.WorkDir = "" - sess := s.getOrCreateSession( + sess, err := s.getOrCreateSession( + user.ID, req.SessionID, req.ProjectName, req.CustomerName, @@ -180,7 +181,17 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { req.UserName, req.UserRole, ) - sess.UserID = user.ID + if err != nil { + if errors.Is(err, ctxmgr.ErrSessionOwnerMismatch) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "SESSION_FORBIDDEN", + "message": "无权访问该会话", + }) + return + } + writeSafeError(w, r, http.StatusInternalServerError, "resolve session failed: "+err.Error()) + return + } s.hydrateVisibleHistory(r.Context(), sess, user.ID) if req.ProjectID > 0 { sess.ProjectID = req.ProjectID @@ -982,6 +993,10 @@ func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") return } + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } var req upsertChatSessionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -992,9 +1007,17 @@ func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) writeSafeError(w, r, http.StatusBadRequest, "missing session_id") return } + req.UserID = user.ID - summary, err := s.ctxMgr.EnsureChatSession(r.Context(), req.SessionID, req.UserID, req.ProjectID, req.Title) + summary, err := s.ctxMgr.EnsureChatSessionOwned(r.Context(), req.SessionID, req.UserID, req.ProjectID, req.Title) if err != nil { + if errors.Is(err, ctxmgr.ErrSessionOwnerMismatch) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "SESSION_FORBIDDEN", + "message": "无权操作该会话", + }) + return + } writeSafeError(w, r, http.StatusInternalServerError, "upsert chat session failed: "+err.Error()) return } diff --git a/internal/server/server.go b/internal/server/server.go index 630b3276..ba3e5fc3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -391,8 +391,9 @@ func (s *Server) cleanStaleSessionsLocked(ttl time.Duration) int { return cleaned } -// getOrCreateSession 获取或动态构建会话 +// getOrCreateSession 获取或动态构建会话。callerUserID>0 时校验持久层归属,不匹配返回 ErrSessionOwnerMismatch。 func (s *Server) getOrCreateSession( + callerUserID int64, sessionID string, projectName string, customerName string, @@ -402,25 +403,38 @@ func (s *Server) getOrCreateSession( workDir string, userName string, userRole string, -) *chat.Session { +) (*chat.Session, error) { if sessionID == "" { sessionID = fmt.Sprintf("sess_%d", time.Now().UnixNano()) } + // 归属校验必须先于任何缓存命中/持久层灌入 + if callerUserID > 0 && s.ctxMgr != nil { + if owner, err := s.ctxMgr.GetSessionOwnerID(context.Background(), sessionID); err == nil && owner > 0 && owner != callerUserID { + return nil, ctxmgr.ErrSessionOwnerMismatch + } + } + s.sessionsMu.RLock() existing, exists := s.sessions[sessionID] s.sessionsMu.RUnlock() if exists && existing != nil { + if callerUserID > 0 && existing.UserID > 0 && existing.UserID != callerUserID { + return nil, ctxmgr.ErrSessionOwnerMismatch + } existing.LastAccessedAt = time.Now() - return existing + return existing, nil } s.sessionsMu.Lock() defer s.sessionsMu.Unlock() // 双重检查 if existing, exists := s.sessions[sessionID]; exists && existing != nil { + if callerUserID > 0 && existing.UserID > 0 && existing.UserID != callerUserID { + return nil, ctxmgr.ErrSessionOwnerMismatch + } existing.LastAccessedAt = time.Now() - return existing + return existing, nil } // 惰性内存防泄漏保护 (会话数超过 200 时清理空闲超 120 分钟会话) @@ -549,9 +563,14 @@ func (s *Server) getOrCreateSession( } } + // 新建会话时立即绑定调用者。NewSession 历史占位 UserID=1 不能当作已绑定归属。 + if callerUserID > 0 && (restored == nil || restored.UserID <= 0) { + sess.UserID = callerUserID + } + sess.LastAccessedAt = time.Now() s.sessions[sessionID] = sess - return sess + return sess, nil } func (s *Server) hydrateVisibleHistory(ctx context.Context, sess *chat.Session, viewerUserID int64) { diff --git a/internal/server/sse.go b/internal/server/sse.go index c51730af..fbc03db0 100644 --- a/internal/server/sse.go +++ b/internal/server/sse.go @@ -173,7 +173,8 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { req.WorkDir = "" // 会话与项目绑定必须在 SSE headers/Flush 之前完成授权(与 tasks/run/stream 对齐) - sess := s.getOrCreateSession( + sess, err := s.getOrCreateSession( + user.ID, req.SessionID, req.ProjectName, req.CustomerName, @@ -184,7 +185,17 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { req.UserName, req.UserRole, ) - sess.UserID = req.UserID + if err != nil { + if errors.Is(err, ctxmgr.ErrSessionOwnerMismatch) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "SESSION_FORBIDDEN", + "message": "无权访问该会话", + }) + return + } + writeSafeError(w, r, http.StatusInternalServerError, "resolve session failed: "+err.Error()) + return + } s.hydrateVisibleHistory(r.Context(), sess, user.ID) if req.ProjectID > 0 { sess.ProjectID = req.ProjectID @@ -306,7 +317,6 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { } // 4. 同 session 串行执行后发起流式对话 - var err error execErr := sess.WithExecLock(func() error { var sendErr error _, sendErr = sess.SendMessageStreamTurn(ctx, req.Message, listener, req.IncludeDetails, turnOpts) From a805a6bfd7a66212d9005476df788048c8eed51f Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 18 Sep 2026 03:39:08 +0000 Subject: [PATCH 03/16] =?UTF-8?q?fix(server):=20chat/sessions=20upsert=20?= =?UTF-8?q?=E4=B8=8E=E4=BB=BB=E5=8A=A1=E5=88=9B=E5=BB=BA=E5=BC=BA=E5=88=B6?= =?UTF-8?q?=E5=8F=96=20principal=EF=BC=88R-1b=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit upsert 忽略客户端自报 user_id;EnsureChatSessionOwned 拒绝跨用户挂靠。 Co-authored-by: Cursor --- internal/server/task_run_handlers.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index 587da2d4..edb08c84 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -106,7 +106,16 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { ctxMgr := s.ctxMgr if ctxMgr != nil { - _, _ = ctxMgr.EnsureChatSession(r.Context(), req.SessionID, req.UserID, req.ProjectID, req.ProjectName) + if _, err := ctxMgr.EnsureChatSessionOwned(r.Context(), req.SessionID, req.UserID, req.ProjectID, req.ProjectName); err != nil { + if errors.Is(err, ctxmgr.ErrSessionOwnerMismatch) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "SESSION_FORBIDDEN", + "message": "无权访问该会话", + }) + return + } + // 其余错误维持原 best-effort 语义 + } } taskScope, scopeErr := s.resolveTaskMessageScope(r.Context(), req.ProjectID, req.TaskKey) From ba1fc71f165c06bddbcbc88a8cb555015aae20a6 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 18 Sep 2026 03:39:08 +0000 Subject: [PATCH 04/16] =?UTF-8?q?test(server):=20=E8=B7=A8=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E4=BC=9A=E8=AF=9D=E6=8C=82=E9=9D=A0=E8=B4=9F=E4=BE=8B?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=88PG=20+=20=E5=86=85=E5=AD=98=E5=8F=8C?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖 chat / chat/stream / chat/sessions 三条路径;受害者续用不受影响。 Co-authored-by: Cursor --- .../server/chat_session_ownership_pg_test.go | 79 ++++++++++++ .../server/chat_session_ownership_test.go | 116 ++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 internal/server/chat_session_ownership_pg_test.go create mode 100644 internal/server/chat_session_ownership_test.go diff --git a/internal/server/chat_session_ownership_pg_test.go b/internal/server/chat_session_ownership_pg_test.go new file mode 100644 index 00000000..011aca25 --- /dev/null +++ b/internal/server/chat_session_ownership_pg_test.go @@ -0,0 +1,79 @@ +package server_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestChatSessionOwnership_CrossUserHijackDenied_PG(t *testing.T) { + pool := openPGPoolWithMigrations(t) + if pool == nil { + return + } + defer pool.Close() + + handler, _ := setupPGGovernanceServer(t, pool) + victimToken := loginTestUser(t, handler, "zhanggong", "123456") + attackerToken := loginTestUser(t, handler, "lizhuguan", "123456") + + sessionID := "sess-hijack-pg-" + time.Now().Format("150405.000") + + victimBody, _ := json.Marshal(map[string]any{"session_id": sessionID, "message": "机密立项讨论"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/chat", bytes.NewReader(victimBody)) + req.Header.Set("Content-Type", "application/json") + withBearerAuth(req, victimToken) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("victim chat: %d %s", rec.Code, rec.Body.String()) + } + + attackerBody, _ := json.Marshal(map[string]any{"session_id": sessionID, "message": "尝试接管"}) + req2 := httptest.NewRequest(http.MethodPost, "/api/v1/chat", bytes.NewReader(attackerBody)) + req2.Header.Set("Content-Type", "application/json") + withBearerAuth(req2, attackerToken) + rec2 := httptest.NewRecorder() + handler.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusForbidden { + t.Fatalf("attacker hijack expected 403, got %d: %s", rec2.Code, rec2.Body.String()) + } + if !strings.Contains(rec2.Body.String(), "SESSION_FORBIDDEN") { + t.Fatalf("expected SESSION_FORBIDDEN, got %s", rec2.Body.String()) + } + + req3 := httptest.NewRequest(http.MethodPost, "/api/v1/chat/stream", bytes.NewReader(attackerBody)) + req3.Header.Set("Content-Type", "application/json") + withBearerAuth(req3, attackerToken) + rec3 := httptest.NewRecorder() + handler.ServeHTTP(rec3, req3) + if rec3.Code != http.StatusForbidden { + t.Fatalf("attacker stream hijack expected 403, got %d", rec3.Code) + } + if strings.Contains(rec3.Header().Get("Content-Type"), "text/event-stream") { + t.Fatal("must reject before SSE headers are set") + } + + upsertBody, _ := json.Marshal(map[string]any{"session_id": sessionID, "user_id": 999999, "title": "hijack via upsert"}) + req4 := httptest.NewRequest(http.MethodPost, "/api/v1/chat/sessions", bytes.NewReader(upsertBody)) + req4.Header.Set("Content-Type", "application/json") + withBearerAuth(req4, attackerToken) + rec4 := httptest.NewRecorder() + handler.ServeHTTP(rec4, req4) + if rec4.Code != http.StatusForbidden { + t.Fatalf("attacker upsert hijack expected 403, got %d: %s", rec4.Code, rec4.Body.String()) + } + + req5 := httptest.NewRequest(http.MethodPost, "/api/v1/chat", bytes.NewReader(victimBody)) + req5.Header.Set("Content-Type", "application/json") + withBearerAuth(req5, victimToken) + rec5 := httptest.NewRecorder() + handler.ServeHTTP(rec5, req5) + if rec5.Code != http.StatusOK { + t.Fatalf("victim continued access broken: %d %s", rec5.Code, rec5.Body.String()) + } +} diff --git a/internal/server/chat_session_ownership_test.go b/internal/server/chat_session_ownership_test.go new file mode 100644 index 00000000..7b5cc149 --- /dev/null +++ b/internal/server/chat_session_ownership_test.go @@ -0,0 +1,116 @@ +package server_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/config" + "chat-agent-system/internal/server" +) + +func createAndLoginUser(t *testing.T, handler http.Handler, adminToken, username, password string) string { + t.Helper() + createBody, _ := json.Marshal(map[string]string{ + "username": username, + "name": username, + "initial_password": password, + }) + reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/admin/users", bytes.NewReader(createBody)) + reqCreate.Header.Set("Content-Type", "application/json") + withBearerAuth(reqCreate, adminToken) + recCreate := httptest.NewRecorder() + handler.ServeHTTP(recCreate, reqCreate) + if recCreate.Code != http.StatusOK { + t.Fatalf("admin create user %s: %d %s", username, recCreate.Code, recCreate.Body.String()) + } + + token := loginTestUser(t, handler, username, password) + // 管理员创建的用户可能要求改密;改完后再登录拿稳定 token。 + changeBody, _ := json.Marshal(map[string]string{ + "old_password": password, + "new_password": password + "_ok", + }) + reqChange := httptest.NewRequest(http.MethodPost, "/api/v1/auth/change-password", bytes.NewReader(changeBody)) + reqChange.Header.Set("Content-Type", "application/json") + withBearerAuth(reqChange, token) + recChange := httptest.NewRecorder() + handler.ServeHTTP(recChange, reqChange) + if recChange.Code == http.StatusOK { + return loginTestUser(t, handler, username, password+"_ok") + } + return token +} + +func TestChatSessionOwnership_CrossUserHijackDenied_Memory(t *testing.T) { + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"} + svc := bizdev.MustNewService(t, cfg) + srv := server.NewServer(cfg, svc) + handler := srv.Routes() + adminToken := loginTestAdmin(t, handler, cfg) + + suffix := time.Now().Format("150405.000") + victimToken := createAndLoginUser(t, handler, adminToken, "hijack_victim_"+suffix, "pass12345") + attackerToken := createAndLoginUser(t, handler, adminToken, "hijack_attacker_"+suffix, "pass12345") + + sessionID := "sess-hijack-" + suffix + + victimBody, _ := json.Marshal(map[string]any{"session_id": sessionID, "message": "机密立项讨论"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/chat", bytes.NewReader(victimBody)) + req.Header.Set("Content-Type", "application/json") + withBearerAuth(req, victimToken) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("victim chat: %d %s", rec.Code, rec.Body.String()) + } + + attackerBody, _ := json.Marshal(map[string]any{"session_id": sessionID, "message": "尝试接管"}) + req2 := httptest.NewRequest(http.MethodPost, "/api/v1/chat", bytes.NewReader(attackerBody)) + req2.Header.Set("Content-Type", "application/json") + withBearerAuth(req2, attackerToken) + rec2 := httptest.NewRecorder() + handler.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusForbidden { + t.Fatalf("attacker hijack expected 403, got %d: %s", rec2.Code, rec2.Body.String()) + } + if !strings.Contains(rec2.Body.String(), "SESSION_FORBIDDEN") { + t.Fatalf("expected SESSION_FORBIDDEN, got %s", rec2.Body.String()) + } + + req3 := httptest.NewRequest(http.MethodPost, "/api/v1/chat/stream", bytes.NewReader(attackerBody)) + req3.Header.Set("Content-Type", "application/json") + withBearerAuth(req3, attackerToken) + rec3 := httptest.NewRecorder() + handler.ServeHTTP(rec3, req3) + if rec3.Code != http.StatusForbidden { + t.Fatalf("attacker stream hijack expected 403, got %d", rec3.Code) + } + if strings.Contains(rec3.Header().Get("Content-Type"), "text/event-stream") { + t.Fatal("must reject before SSE headers are set") + } + + upsertBody, _ := json.Marshal(map[string]any{"session_id": sessionID, "user_id": 999999, "title": "hijack via upsert"}) + req4 := httptest.NewRequest(http.MethodPost, "/api/v1/chat/sessions", bytes.NewReader(upsertBody)) + req4.Header.Set("Content-Type", "application/json") + withBearerAuth(req4, attackerToken) + rec4 := httptest.NewRecorder() + handler.ServeHTTP(rec4, req4) + if rec4.Code != http.StatusForbidden { + t.Fatalf("attacker upsert hijack expected 403, got %d: %s", rec4.Code, rec4.Body.String()) + } + + req5 := httptest.NewRequest(http.MethodPost, "/api/v1/chat", bytes.NewReader(victimBody)) + req5.Header.Set("Content-Type", "application/json") + withBearerAuth(req5, victimToken) + rec5 := httptest.NewRecorder() + handler.ServeHTTP(rec5, req5) + if rec5.Code != http.StatusOK { + t.Fatalf("victim continued access broken: %d %s", rec5.Code, rec5.Body.String()) + } +} From b53a97b36d394222d44c38ee91ca0bb4367525d9 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 18 Sep 2026 03:40:24 +0000 Subject: [PATCH 05/16] =?UTF-8?q?fix(ctxmgr):=20=E8=A1=A5=E9=BD=90=20rows.?= =?UTF-8?q?Err()=20=E6=A3=80=E6=9F=A5=EF=BC=88R-2=EF=BC=8Cctxmgr=20?= =?UTF-8?q?=E5=8C=85=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 类硬失败返回;manager 降级读路径用 slog.Warn 保持既有容忍语义。 Co-authored-by: Cursor --- internal/ctxmgr/case_indexing.go | 2 +- internal/ctxmgr/chat_digest.go | 6 ++++++ internal/ctxmgr/chat_history.go | 4 ++-- internal/ctxmgr/chat_memory.go | 3 +++ internal/ctxmgr/chat_search.go | 7 +++++-- internal/ctxmgr/chat_unread.go | 4 ++++ internal/ctxmgr/chat_window.go | 8 +++++++- internal/ctxmgr/manager.go | 13 +++++++++++++ internal/ctxmgr/project_dialogue.go | 3 +++ internal/ctxmgr/recategorize.go | 3 +++ 10 files changed, 47 insertions(+), 6 deletions(-) diff --git a/internal/ctxmgr/case_indexing.go b/internal/ctxmgr/case_indexing.go index 6cf2a76f..7aa2142c 100644 --- a/internal/ctxmgr/case_indexing.go +++ b/internal/ctxmgr/case_indexing.go @@ -196,7 +196,7 @@ func (m *ContextManager) SearchSimilarCases(ctx context.Context, opts CaseSearch results = append(results, item) } } - return results, nil + return results, rows.Err() } m.mu.RLock() diff --git a/internal/ctxmgr/chat_digest.go b/internal/ctxmgr/chat_digest.go index 2a0adf1f..508ed506 100644 --- a/internal/ctxmgr/chat_digest.go +++ b/internal/ctxmgr/chat_digest.go @@ -50,6 +50,9 @@ func (m *ContextManager) GenerateDailyDigest(ctx context.Context, sessionID, day rec.CreatedAt = createdAt msgs = append(msgs, rec) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan digest messages failed: %w", err) + } } else { m.mu.RLock() for _, r := range m.messages[sessionID] { @@ -367,6 +370,9 @@ func (m *ContextManager) ListDailyDigests(ctx context.Context, sessionID, from, _ = json.Unmarshal(oqBytes, &d.OpenQuestions) digests = append(digests, d) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan digests failed: %w", err) + } return digests, nil } diff --git a/internal/ctxmgr/chat_history.go b/internal/ctxmgr/chat_history.go index 54a189e1..d61dd695 100644 --- a/internal/ctxmgr/chat_history.go +++ b/internal/ctxmgr/chat_history.go @@ -152,7 +152,7 @@ func (m *ContextManager) ListChatSessions(ctx context.Context, filter SessionFil sessions = append(sessions, item) } } - return sessions, nil + return sessions, rows.Err() } m.mu.RLock() @@ -224,7 +224,7 @@ func (m *ContextManager) GetChatHistory(ctx context.Context, sessionID string, l msgs = append(msgs, rec) } } - return msgs, nil + return msgs, rows.Err() } m.mu.RLock() diff --git a/internal/ctxmgr/chat_memory.go b/internal/ctxmgr/chat_memory.go index 5b0ec34b..e75d70d8 100644 --- a/internal/ctxmgr/chat_memory.go +++ b/internal/ctxmgr/chat_memory.go @@ -285,6 +285,9 @@ func (m *ContextManager) SearchChatMemory(ctx context.Context, opts ChatMemorySe } } } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan memory chunks failed: %w", err) + } sort.Slice(candidates, func(i, j int) bool { return candidates[i].FinalScore > candidates[j].FinalScore diff --git a/internal/ctxmgr/chat_search.go b/internal/ctxmgr/chat_search.go index 88b84a36..375309d4 100644 --- a/internal/ctxmgr/chat_search.go +++ b/internal/ctxmgr/chat_search.go @@ -101,7 +101,7 @@ func (m *ContextManager) searchChatMessagesKeywordDB(ctx context.Context, q Chat MatchedBy: "keyword", }) } - return hits, nil + return hits, rows.Err() } // searchChatMessagesFuzzyDB 模糊相似度检索 (Postgres pg_trgm similarity) @@ -160,7 +160,7 @@ func (m *ContextManager) searchChatMessagesFuzzyDB(ctx context.Context, q ChatSe MatchedBy: "fuzzy", }) } - return hits, nil + return hits, rows.Err() } // searchChatMessagesSemanticDB 语义向量检索 (通过 context_memory_chunks 或降级为 keyword) @@ -232,6 +232,9 @@ func (m *ContextManager) searchChatMessagesSemanticDB(ctx context.Context, q Cha MatchedBy: "semantic", }) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan semantic search hits failed: %w", err) + } if len(hits) == 0 { return m.searchChatMessagesKeywordDB(ctx, q) diff --git a/internal/ctxmgr/chat_unread.go b/internal/ctxmgr/chat_unread.go index d2a768de..b2d5499b 100644 --- a/internal/ctxmgr/chat_unread.go +++ b/internal/ctxmgr/chat_unread.go @@ -322,6 +322,10 @@ func (m *ContextManager) markChatReadPG(ctx context.Context, req MarkChatReadReq futureIDs = append(futureIDs, mid) } } + if err := rows.Err(); err != nil { + rows.Close() + return nil, fmt.Errorf("scan future unread ids failed: %w", err) + } rows.Close() } diff --git a/internal/ctxmgr/chat_window.go b/internal/ctxmgr/chat_window.go index 22cf0420..e735bd55 100644 --- a/internal/ctxmgr/chat_window.go +++ b/internal/ctxmgr/chat_window.go @@ -72,6 +72,9 @@ func (m *ContextManager) getChatWindowPG(ctx context.Context, q ChatWindowQuery) descDays = append(descDays, d.Format("2006-01-02")) } } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan chat window days failed: %w", err) + } if len(descDays) == 0 { return &ChatWindowResult{ @@ -601,6 +604,9 @@ func (m *ContextManager) ListChatDates(ctx context.Context, sessionID string) ([ st.Categories[cat] += cnt } } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan chat dates failed: %w", err) + } res := make([]ChatDateStat, 0, len(orderedKeys)) for _, k := range orderedKeys { @@ -688,7 +694,7 @@ func (m *ContextManager) ListChatCategories(ctx context.Context, sessionID strin LastMessageID: maxID, }) } - return res, nil + return res, rows.Err() } m.mu.RLock() diff --git a/internal/ctxmgr/manager.go b/internal/ctxmgr/manager.go index 80a80ba4..4e8d79b8 100644 --- a/internal/ctxmgr/manager.go +++ b/internal/ctxmgr/manager.go @@ -1,6 +1,7 @@ package ctxmgr import ( + "log/slog" "context" "encoding/json" "errors" @@ -383,6 +384,9 @@ func (m *ContextManager) GetUserContext(ctx context.Context, projectID, userID i userCtx.RecentChats = append(userCtx.RecentChats, r) } } + if err := rows.Err(); err != nil { + slog.Warn("ctxmgr_recent_chats_scan_incomplete", "error", err) + } } } else { m.mu.RLock() @@ -410,6 +414,9 @@ func (m *ContextManager) GetUserContext(ctx context.Context, projectID, userID i rawActs = append(rawActs, it) } } + if err := rows.Err(); err != nil { + slog.Warn("ctxmgr_user_activities_scan_incomplete", "error", err) + } } } else { m.mu.RLock() @@ -481,6 +488,9 @@ func (m *ContextManager) GetProjectContext(ctx context.Context, projectID int64) } } } + if err := rows.Err(); err != nil { + slog.Warn("ctxmgr_project_activities_scan_incomplete", "error", err) + } pCtx.AllActivities = ApplyTimeDecayToActivities(rawActs, m.decayCfg) } } else { @@ -727,6 +737,9 @@ func (m *ContextManager) ReflectUserPreference(ctx context.Context, userID int64 msgs = append(msgs, r) } } + if err := rows.Err(); err != nil { + slog.Warn("ctxmgr_reflect_msgs_scan_incomplete", "error", err) + } } } else { m.mu.RLock() diff --git a/internal/ctxmgr/project_dialogue.go b/internal/ctxmgr/project_dialogue.go index 69a0467e..49d3b2d0 100644 --- a/internal/ctxmgr/project_dialogue.go +++ b/internal/ctxmgr/project_dialogue.go @@ -69,6 +69,9 @@ func (m *ContextManager) CollectProjectDialogue(ctx context.Context, projectID i } msgs = append(msgs, rec) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan project dialogue failed: %w", err) + } } else { m.mu.RLock() for sessionID, sessionMsgs := range m.messages { diff --git a/internal/ctxmgr/recategorize.go b/internal/ctxmgr/recategorize.go index 98250818..2a8a4c8f 100644 --- a/internal/ctxmgr/recategorize.go +++ b/internal/ctxmgr/recategorize.go @@ -64,6 +64,9 @@ func (m *ContextManager) RecategorizeMessages(ctx context.Context, sessionID str } msgs = append(msgs, r) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan messages for recategorize failed: %w", err) + } var lastUserMsg *recatMsg for _, msg := range msgs { From 9038c653290a7d4037165026ff5cb93efc449c52 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 18 Sep 2026 03:40:24 +0000 Subject: [PATCH 06/16] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=20rows.Err()=20?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=EF=BC=88R-2=EF=BC=8C=E5=85=B6=E4=BD=99?= =?UTF-8?q?=E5=8C=85=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖 kb/storage/projectworkflow/docgov/freedoc 与两个 document CLI。 Co-authored-by: Cursor --- cmd/document-content-reconcile/main.go | 4 ++++ cmd/document-revision-backfill/main.go | 4 ++++ internal/docgov/engine.go | 7 +++++++ internal/freedoc/service.go | 2 +- internal/kb/retrieve.go | 2 +- internal/projectworkflow/store.go | 12 ++++++------ internal/storage/cas.go | 3 +++ internal/storage/cas_assets.go | 2 +- 8 files changed, 27 insertions(+), 9 deletions(-) diff --git a/cmd/document-content-reconcile/main.go b/cmd/document-content-reconcile/main.go index a0af1fb0..7fa1f4e6 100644 --- a/cmd/document-content-reconcile/main.go +++ b/cmd/document-content-reconcile/main.go @@ -111,6 +111,10 @@ func main() { os.Exit(1) } } + if err := rows.Err(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } enc, _ := json.MarshalIndent(report, "", " ") if *outPath != "" { _ = os.WriteFile(*outPath, enc, 0o644) diff --git a/cmd/document-revision-backfill/main.go b/cmd/document-revision-backfill/main.go index 04117787..6d4c8814 100644 --- a/cmd/document-revision-backfill/main.go +++ b/cmd/document-revision-backfill/main.go @@ -116,6 +116,10 @@ func main() { report.Success++ report.Items = append(report.Items, it) } + if err := rows.Err(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } enc, _ := json.MarshalIndent(report, "", " ") if *outPath != "" { _ = os.WriteFile(*outPath, enc, 0o644) diff --git a/internal/docgov/engine.go b/internal/docgov/engine.go index 7363b47b..7e066959 100644 --- a/internal/docgov/engine.go +++ b/internal/docgov/engine.go @@ -1,6 +1,7 @@ package docgov import ( + "log/slog" "context" "encoding/json" "fmt" @@ -1022,6 +1023,9 @@ func (e *GovernanceEngine) ListAuditLogs(ctx context.Context, docID string) ([]* e.memAudits[docID] = append(e.memAudits[docID], &a) } } + if err := rows.Err(); err != nil { + slog.Warn("docgov_audit_scan_incomplete", "error", err) + } } } @@ -1058,6 +1062,9 @@ func (e *GovernanceEngine) ListComments(ctx context.Context, docID string) ([]*D e.memComms[docID] = append(e.memComms[docID], &c) } } + if err := rows.Err(); err != nil { + slog.Warn("docgov_comments_scan_incomplete", "error", err) + } } } diff --git a/internal/freedoc/service.go b/internal/freedoc/service.go index b464e837..16f9eb73 100644 --- a/internal/freedoc/service.go +++ b/internal/freedoc/service.go @@ -583,7 +583,7 @@ func (s *Service) ListDocuments(ctx context.Context, userID int, projectID int, } results = append(results, sm) } - return results, nil + return results, rows.Err() } // 内存模式 diff --git a/internal/kb/retrieve.go b/internal/kb/retrieve.go index 5f4b1c89..05b23f71 100644 --- a/internal/kb/retrieve.go +++ b/internal/kb/retrieve.go @@ -266,7 +266,7 @@ func (r *Retriever) searchProjectAsset(ctx context.Context, q string, opts Searc Source: "项目数字资产库", }) } - return out, nil + return out, rows.Err() } func regStatusFilter(includeRepealed bool) string { diff --git a/internal/projectworkflow/store.go b/internal/projectworkflow/store.go index 9e5cf422..4b909857 100644 --- a/internal/projectworkflow/store.go +++ b/internal/projectworkflow/store.go @@ -201,7 +201,7 @@ func (s *pgStore) ListProjectsForUser(ctx context.Context, userID int64) ([]*Pro } list = append(list, p) } - return list, nil + return list, rows.Err() } func (s *pgStore) ListAllProjects(ctx context.Context) ([]*Project, error) { @@ -258,7 +258,7 @@ func (s *pgStore) ListProjectWorkflows(ctx context.Context, projectID int64) ([] } list = append(list, pw) } - return list, nil + return list, rows.Err() } func (s *pgStore) EnsureProjectWorkflows(ctx context.Context, projectID int64, defaultKeys []string) error { @@ -484,7 +484,7 @@ func (s *pgStore) ListWorkflowRuns(ctx context.Context, projectID int64, workflo } list = append(list, run) } - return list, nil + return list, rows.Err() } func (s *pgStore) RecordLineage(ctx context.Context, lineage *DocumentLineage) error { @@ -532,7 +532,7 @@ func (s *pgStore) GetLineageForDownstream(ctx context.Context, docID string, ver } list = append(list, l) } - return list, nil + return list, rows.Err() } func (s *pgStore) GetLineagesForUpstream(ctx context.Context, upstreamDocID string) ([]*DocumentLineage, error) { @@ -560,7 +560,7 @@ func (s *pgStore) GetLineagesForUpstream(ctx context.Context, upstreamDocID stri } list = append(list, l) } - return list, nil + return list, rows.Err() } func (s *pgStore) MarkDownstreamStale(ctx context.Context, upstreamDocID string, staleReason string) ([]*DocumentLineage, error) { @@ -590,7 +590,7 @@ func (s *pgStore) MarkDownstreamStale(ctx context.Context, upstreamDocID string, } updated = append(updated, l) } - return updated, nil + return updated, rows.Err() } // ============================================================================= diff --git a/internal/storage/cas.go b/internal/storage/cas.go index 259a7e7d..106d543f 100644 --- a/internal/storage/cas.go +++ b/internal/storage/cas.go @@ -447,6 +447,9 @@ func (c *CAS) searchMemoryDB(ctx context.Context, opts MemorySearchOptions) ([]* FinalScore: finalScore, }) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan cas chunks failed: %w", err) + } // 按 FinalScore 降序排列 sortChunkHits(hits) diff --git a/internal/storage/cas_assets.go b/internal/storage/cas_assets.go index d08a1ee4..01f1b3d4 100644 --- a/internal/storage/cas_assets.go +++ b/internal/storage/cas_assets.go @@ -31,7 +31,7 @@ func (c *CAS) ListProjectRefs(ctx context.Context, projectID int64) ([]*FileRefe } refs = append(refs, ref) } - return refs, nil + return refs, rows.Err() } c.mu.RLock() From 207e26241c2d28c827ffdd1687137453bd7dfc36 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 18 Sep 2026 03:44:08 +0000 Subject: [PATCH 07/16] =?UTF-8?q?fix(events):=20dispatcher=20Handlers=20?= =?UTF-8?q?=E8=AF=BB=E8=B7=AF=E5=BE=84=E5=8A=A0=20RWMutex=EF=BC=88R-3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register 写锁不变;shadowScan/handle 读路径 RLock,消除导出字段无锁读隐患。 Co-authored-by: Cursor --- internal/events/worker.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/internal/events/worker.go b/internal/events/worker.go index 5b5be912..4ec0cd7d 100644 --- a/internal/events/worker.go +++ b/internal/events/worker.go @@ -22,10 +22,11 @@ type Dispatcher struct { Batch int Lease time.Duration Heartbeat time.Duration - Handlers map[string]Handler - Metrics *Metrics + // Handlers 并发读写受 mu 保护;请勿绕过 Register() 直接写此字段。 + Handlers map[string]Handler + Metrics *Metrics - mu sync.Mutex + mu sync.RWMutex claiming bool inflight map[string]struct{} cancelFns []context.CancelFunc @@ -151,7 +152,11 @@ func (d *Dispatcher) shadowScan(ctx context.Context, consumer string) (int, erro if err != nil { return 0, err } - h := d.Handlers[consumer] + h := func() Handler { + d.mu.RLock() + defer d.mu.RUnlock() + return d.Handlers[consumer] + }() var n int for _, lease := range leases { digest := Digest([]byte(lease.EventID + ":" + consumer + ":" + ModeShadow)) @@ -231,7 +236,9 @@ func (d *Dispatcher) handle(ctx context.Context, lease Lease) { }() defer close(done) + d.mu.RLock() h := d.Handlers[lease.Consumer] + d.mu.RUnlock() if h == nil { _ = d.fail(ctx, lease, "NO_HANDLER", true) return From e25a197410ba1df374389079fe7b74ed754c4bf4 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 18 Sep 2026 03:44:08 +0000 Subject: [PATCH 08/16] =?UTF-8?q?fix(ctxmgr):=20memTurns=20=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=AE=9E=E4=BE=8B=E4=BD=9C=E7=94=A8=E5=9F=9F=E5=B9=B6?= =?UTF-8?q?=E5=8A=A0=20TTL/=E5=AE=B9=E9=87=8F=E6=B7=98=E6=B1=B0=EF=BC=88R-?= =?UTF-8?q?4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 消除跨 ContextManager 实例共享;超过容量时淘汰 2h 陈旧回合。 Co-authored-by: Cursor --- internal/ctxmgr/chat_turns.go | 62 ++++++++++++++++++++---------- internal/ctxmgr/chat_turns_test.go | 34 ++++++++++++++-- internal/ctxmgr/manager.go | 2 + 3 files changed, 74 insertions(+), 24 deletions(-) diff --git a/internal/ctxmgr/chat_turns.go b/internal/ctxmgr/chat_turns.go index 2d2c7c84..b31d2527 100644 --- a/internal/ctxmgr/chat_turns.go +++ b/internal/ctxmgr/chat_turns.go @@ -6,7 +6,6 @@ import ( "encoding/hex" "errors" "fmt" - "sync" "time" ) @@ -165,8 +164,25 @@ func (m *ContextManager) acquireChatTurnDB( return &AcquireTurnResult{Record: rec, WonLease: true}, nil } -var memTurnsMu sync.Mutex -var memTurns = map[string]*ChatTurnRecord{} // sessionID|clientTurnID -> record + +const maxMemTurns = 2000 +const memTurnTTL = 2 * time.Hour + +// pruneStaleTurnsLocked 调用方需持有 m.mu 写锁 +func (m *ContextManager) pruneStaleTurnsLocked(ttl time.Duration) int { + if ttl <= 0 { + ttl = memTurnTTL + } + now := time.Now() + cleaned := 0 + for k, rec := range m.turns { + if rec == nil || now.Sub(rec.UpdatedAt) > ttl { + delete(m.turns, k) + cleaned++ + } + } + return cleaned +} func turnMemKey(sessionID, clientTurnID string) string { return sessionID + "|" + clientTurnID @@ -176,11 +192,11 @@ func (m *ContextManager) acquireChatTurnMemory( sessionID, clientTurnID, bodyHash, requestID string, userID, projectID int64, ) (*AcquireTurnResult, error) { - memTurnsMu.Lock() - defer memTurnsMu.Unlock() + m.mu.Lock() + defer m.mu.Unlock() key := turnMemKey(sessionID, clientTurnID) - if existing, ok := memTurns[key]; ok { + if existing, ok := m.turns[key]; ok { if existing.RequestBodySHA256 != bodyHash { return nil, ErrIdempotencyKeyReused } @@ -205,7 +221,7 @@ func (m *ContextManager) acquireChatTurnMemory( now := time.Now() leaseUntil := now.Add(5 * time.Minute) - if existing, ok := memTurns[key]; ok { + if existing, ok := m.turns[key]; ok { existing.Status = TurnStatusProcessing existing.OwnerToken = fmt.Sprintf("mem_%d", now.UnixNano()) existing.LastRequestID = requestID @@ -215,8 +231,12 @@ func (m *ContextManager) acquireChatTurnMemory( return &AcquireTurnResult{Record: existing, WonLease: true}, nil } + if len(m.turns) > maxMemTurns { + m.pruneStaleTurnsLocked(memTurnTTL) + } + rec := &ChatTurnRecord{ - ID: int64(len(memTurns) + 1), + ID: int64(len(m.turns) + 1), SessionID: sessionID, ClientTurnID: clientTurnID, UserID: userID, @@ -231,7 +251,7 @@ func (m *ContextManager) acquireChatTurnMemory( UpdatedAt: now, LeaseExpiresAt: &leaseUntil, } - memTurns[key] = rec + m.turns[key] = rec return &AcquireTurnResult{Record: rec, WonLease: true}, nil } @@ -244,9 +264,9 @@ func (m *ContextManager) UpdateChatTurnUserMessage(ctx context.Context, sessionI `, userMessageID, sessionID, clientTurnID) return err } - memTurnsMu.Lock() - defer memTurnsMu.Unlock() - if rec, ok := memTurns[turnMemKey(sessionID, clientTurnID)]; ok { + m.mu.Lock() + defer m.mu.Unlock() + if rec, ok := m.turns[turnMemKey(sessionID, clientTurnID)]; ok { rec.UserMessageID = userMessageID rec.UpdatedAt = time.Now() } @@ -264,9 +284,9 @@ func (m *ContextManager) CompleteChatTurn(ctx context.Context, sessionID, client `, TurnStatusCompleted, assistantMessageID, now, sessionID, clientTurnID) return err } - memTurnsMu.Lock() - defer memTurnsMu.Unlock() - if rec, ok := memTurns[turnMemKey(sessionID, clientTurnID)]; ok { + m.mu.Lock() + defer m.mu.Unlock() + if rec, ok := m.turns[turnMemKey(sessionID, clientTurnID)]; ok { rec.Status = TurnStatusCompleted rec.AssistantMessageID = assistantMessageID rec.CompletedAt = &now @@ -284,9 +304,9 @@ func (m *ContextManager) FailChatTurn(ctx context.Context, sessionID, clientTurn `, TurnStatusFailed, sessionID, clientTurnID) return err } - memTurnsMu.Lock() - defer memTurnsMu.Unlock() - if rec, ok := memTurns[turnMemKey(sessionID, clientTurnID)]; ok { + m.mu.Lock() + defer m.mu.Unlock() + if rec, ok := m.turns[turnMemKey(sessionID, clientTurnID)]; ok { rec.Status = TurnStatusFailed rec.UpdatedAt = time.Now() } @@ -323,9 +343,9 @@ func (m *ContextManager) GetChatTurn(ctx context.Context, sessionID, clientTurnI rec.LeaseExpiresAt = leaseExpiresAt return &rec, nil } - memTurnsMu.Lock() - defer memTurnsMu.Unlock() - rec, ok := memTurns[turnMemKey(sessionID, clientTurnID)] + m.mu.Lock() + defer m.mu.Unlock() + rec, ok := m.turns[turnMemKey(sessionID, clientTurnID)] if !ok { return nil, ErrChatTurnNotFound } diff --git a/internal/ctxmgr/chat_turns_test.go b/internal/ctxmgr/chat_turns_test.go index 354ad713..8e0dae68 100644 --- a/internal/ctxmgr/chat_turns_test.go +++ b/internal/ctxmgr/chat_turns_test.go @@ -2,12 +2,13 @@ package ctxmgr import ( "context" + "strconv" "sync" "testing" + "time" ) func TestAcquireChatTurn_DuplicateSameBody(t *testing.T) { - memTurns = map[string]*ChatTurnRecord{} mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) ctx := context.Background() sessionID := "sess-dup" @@ -32,7 +33,6 @@ func TestAcquireChatTurn_DuplicateSameBody(t *testing.T) { } func TestAcquireChatTurn_ConcurrentDuplicate(t *testing.T) { - memTurns = map[string]*ChatTurnRecord{} mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) ctx := context.Background() sessionID := "sess-conc" @@ -65,7 +65,6 @@ func TestAcquireChatTurn_ConcurrentDuplicate(t *testing.T) { } func TestAcquireChatTurn_DifferentBodyRejected(t *testing.T) { - memTurns = map[string]*ChatTurnRecord{} mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) ctx := context.Background() sessionID := "sess-reuse" @@ -80,3 +79,32 @@ func TestAcquireChatTurn_DifferentBodyRejected(t *testing.T) { t.Fatalf("expected ErrIdempotencyKeyReused, got %v", err) } } + +func TestAcquireChatTurn_StaleEntriesPruned(t *testing.T) { + mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig()) + ctx := context.Background() + mgr.mu.Lock() + now := time.Now() + for i := 0; i < maxMemTurns+50; i++ { + key := turnMemKey("sess-prune", "old-"+strconv.Itoa(i)) + mgr.turns[key] = &ChatTurnRecord{ + ID: int64(i + 1), + SessionID: "sess-prune", + ClientTurnID: "old-" + strconv.Itoa(i), + Status: TurnStatusCompleted, + UpdatedAt: now.Add(-3 * time.Hour), + } + } + mgr.mu.Unlock() + bodyHash := HashTurnBody("sess-prune", "fresh") + _, err := mgr.AcquireChatTurn(ctx, "sess-prune", "fresh-turn", bodyHash, "req-fresh", 1, 1) + if err != nil { + t.Fatalf("acquire: %v", err) + } + mgr.mu.RLock() + n := len(mgr.turns) + mgr.mu.RUnlock() + if n > maxMemTurns { + t.Fatalf("expected turns pruned under capacity, got %d", n) + } +} diff --git a/internal/ctxmgr/manager.go b/internal/ctxmgr/manager.go index 4e8d79b8..864ec7a3 100644 --- a/internal/ctxmgr/manager.go +++ b/internal/ctxmgr/manager.go @@ -33,6 +33,7 @@ type ContextManager struct { summaries map[string]*ChatSummary // session_id -> rolling summary digests map[string]map[string]*DailyDigest // session_id -> day_key -> DailyDigest readPositions map[string]*ChatReadPosition // session_id:user_id -> read position + turns map[string]*ChatTurnRecord // sessionID|clientTurnID -> 幂等回合(仅内存降级) cache *MultiLevelCache // 多级统一缓存系统 (C1-C5) semanticSummarizer SemanticSummarizer // no-tool summarizer; nil → heuristic default nextActivityID int64 @@ -60,6 +61,7 @@ func NewContextManager(pool *pgxpool.Pool, cas *storage.CAS, decayCfg TimeDecayC summaries: make(map[string]*ChatSummary), digests: make(map[string]map[string]*DailyDigest), readPositions: make(map[string]*ChatReadPosition), + turns: make(map[string]*ChatTurnRecord), cache: NewMultiLevelCache(), nextActivityID: 1, nextMessageID: 1, From 58b69a2b081656f3c7197e6eb2d435edb66d09b0 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 18 Sep 2026 03:44:08 +0000 Subject: [PATCH 09/16] =?UTF-8?q?fix(docrevision):=20pg=5Fstore=20JSONB=20?= =?UTF-8?q?=E5=8F=8D=E5=BA=8F=E5=88=97=E5=8C=96=E5=A4=B1=E8=B4=A5=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E6=8A=A5=E9=94=99=EF=BC=88R-5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hydrateDocLocked 损坏字段不再静默零值化;补 PG 负例测试。 Co-authored-by: Cursor --- internal/docrevision/pg_store.go | 45 +++++++++--- .../pg_store_hydrate_corrupt_test.go | 68 +++++++++++++++++++ 2 files changed, 102 insertions(+), 11 deletions(-) create mode 100644 internal/docrevision/pg_store_hydrate_corrupt_test.go diff --git a/internal/docrevision/pg_store.go b/internal/docrevision/pg_store.go index 8c8cf96e..a710c122 100644 --- a/internal/docrevision/pg_store.go +++ b/internal/docrevision/pg_store.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "time" "chat-agent-system/internal/app/uow" @@ -65,8 +66,12 @@ func (s *Service) hydrateDocLocked(ctx context.Context, docID string) error { &sn.SourceKind, &sn.ActorUserID, &sn.ActorRef, &actorRaw, &sn.SourceRunID, &sn.CreatedAt); err != nil { return err } - _ = json.Unmarshal(blockRaw, &sn.BlockMap) - _ = json.Unmarshal(actorRaw, &sn.ActorSnapshot) + if err := json.Unmarshal(blockRaw, &sn.BlockMap); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal block_map for doc %s snapshot %d: %w", docID, sn.ID, err) + } + if err := json.Unmarshal(actorRaw, &sn.ActorSnapshot); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal actor_snapshot for doc %s snapshot %d: %w", docID, sn.ID, err) + } snaps = append(snaps, snapRow{snap: sn}) shas[sn.ContentSHA256] = struct{}{} } @@ -141,8 +146,12 @@ func (s *Service) hydrateDocLocked(ctx context.Context, docID string) error { &ev.EditSessionID, &ev.RequestID, &ev.IdempotencyKey, &ev.CreatedAt); err != nil { return err } - _ = json.Unmarshal(actorRaw, &ev.ActorSnapshot) - _ = json.Unmarshal(changeRaw, &ev.ChangeSet) + if err := json.Unmarshal(actorRaw, &ev.ActorSnapshot); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal actor_snapshot for doc %s event %d: %w", docID, ev.ID, err) + } + if err := json.Unmarshal(changeRaw, &ev.ChangeSet); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal change_set for doc %s event %d: %w", docID, ev.ID, err) + } cp := ev s.events[cp.ID] = &cp if cp.IdempotencyKey != "" { @@ -173,12 +182,24 @@ func (s *Service) hydrateDocLocked(ctx context.Context, docID string) error { &rep.ModelSnapshot, &rep.PromptVersion, &factRaw, &rep.CorrectsReportID, &rep.RawJSON, &rep.CreatedAt); err != nil { return err } - _ = json.Unmarshal(sumRaw, &rep.Summary) - _ = json.Unmarshal(riskRaw, &rep.RiskItems) - _ = json.Unmarshal(projRaw, &rep.ProjectImpacts) - _ = json.Unmarshal(docRaw, &rep.DocumentImpacts) - _ = json.Unmarshal(qRaw, &rep.OpenQuestions) - _ = json.Unmarshal(factRaw, &rep.DeterministicFacts) + if err := json.Unmarshal(sumRaw, &rep.Summary); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal summary for doc %s report %d: %w", docID, rep.ID, err) + } + if err := json.Unmarshal(riskRaw, &rep.RiskItems); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal risk_items for doc %s report %d: %w", docID, rep.ID, err) + } + if err := json.Unmarshal(projRaw, &rep.ProjectImpacts); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal project_impacts for doc %s report %d: %w", docID, rep.ID, err) + } + if err := json.Unmarshal(docRaw, &rep.DocumentImpacts); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal document_impacts for doc %s report %d: %w", docID, rep.ID, err) + } + if err := json.Unmarshal(qRaw, &rep.OpenQuestions); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal open_questions for doc %s report %d: %w", docID, rep.ID, err) + } + if err := json.Unmarshal(factRaw, &rep.DeterministicFacts); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal deterministic_facts for doc %s report %d: %w", docID, rep.ID, err) + } cp := rep s.reports[cp.ID] = &cp if cp.ID > s.nextRepID { @@ -205,7 +226,9 @@ func (s *Service) hydrateDocLocked(ctx context.Context, docID string) error { &c.CreatedAt, &c.DecidedAt); err != nil { return err } - _ = json.Unmarshal(actorRaw, &c.ActorSnapshot) + if err := json.Unmarshal(actorRaw, &c.ActorSnapshot); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal actor_snapshot for doc %s candidate %d: %w", docID, c.ID, err) + } cp := c s.candidates[cp.ID] = &cp if cp.ID > s.nextCandID { diff --git a/internal/docrevision/pg_store_hydrate_corrupt_test.go b/internal/docrevision/pg_store_hydrate_corrupt_test.go new file mode 100644 index 00000000..22506950 --- /dev/null +++ b/internal/docrevision/pg_store_hydrate_corrupt_test.go @@ -0,0 +1,68 @@ +package docrevision_test + +import ( + "context" + "strings" + "testing" + + "chat-agent-system/internal/db" + "chat-agent-system/internal/docrevision" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestHydrateDocLocked_CorruptJSONBFailsExplicitly_PG(t *testing.T) { + dsn := db.SkipIfNoTestDatabaseURL(t) + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + + var n int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM information_schema.tables WHERE table_name='document_snapshots'`).Scan(&n); err != nil || n == 0 { + t.Skip("document_snapshots table missing; migrate required") + } + + docID := "doc_hydrate_corrupt_jsonb" + _, _ = pool.Exec(ctx, `DELETE FROM document_change_events WHERE doc_id=$1`, docID) + _, _ = pool.Exec(ctx, `DELETE FROM document_agent_candidates WHERE doc_id=$1`, docID) + _, _ = pool.Exec(ctx, `DELETE FROM document_change_reports WHERE doc_id=$1`, docID) + _, _ = pool.Exec(ctx, `DELETE FROM document_versions WHERE doc_id=$1`, docID) + _, _ = pool.Exec(ctx, `DELETE FROM document_snapshots WHERE doc_id=$1`, docID) + + _, err = pool.Exec(ctx, ` + INSERT INTO document_snapshots ( + doc_id, content_sha256, parent_snapshot_id, block_map, source_kind, + actor_user_id, actor_ref, actor_snapshot, source_run_id, created_at + ) VALUES ( + $1, 'sha_corrupt_test', NULL, '{not-valid-json'::bytea, 'human', + 1, 'tester', '{}'::jsonb, NULL, now() + ) + `, docID) + if err != nil { + // Some schemas store block_map as jsonb (rejects invalid at insert). Fall back to casting via text. + _, err = pool.Exec(ctx, ` + INSERT INTO document_snapshots ( + doc_id, content_sha256, parent_snapshot_id, block_map, source_kind, + actor_user_id, actor_ref, actor_snapshot, source_run_id, created_at + ) VALUES ( + $1, 'sha_corrupt_test', NULL, convert_to('{not-valid-json', 'UTF8'), 'human', + 1, 'tester', '{}'::jsonb, NULL, now() + ) + `, docID) + if err != nil { + t.Skipf("cannot insert corrupt block_map for schema: %v", err) + } + } + + svc := docrevision.NewService(pool) + _, err = svc.GetRevisionState(ctx, docID) + if err == nil { + t.Fatal("expected hydrate error for corrupt block_map JSONB") + } + if !strings.Contains(err.Error(), "hydrateDocLocked") || !strings.Contains(err.Error(), "block_map") { + t.Fatalf("expected explicit hydrate/block_map error, got: %v", err) + } +} From ce7d1b8ac5b4d4546b44b15dc1f8a49e6f20603c Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 18 Sep 2026 03:44:08 +0000 Subject: [PATCH 10/16] =?UTF-8?q?fix(server):=20=E5=90=8E=E5=8F=B0=20ticke?= =?UTF-8?q?r=20=E7=BA=B3=E5=85=A5=20Shutdown=EF=BC=9Bvault=20Drain=20?= =?UTF-8?q?=E5=91=8A=E8=AD=A6=EF=BC=9Bbizdev=20=E5=BF=83=E8=B7=B3=E8=B6=85?= =?UTF-8?q?=E6=97=B6=EF=BC=88R-6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reminder/vault 可停止;Drain 失败 slog.Warn;心跳/释放 lease 使用 10s 超时。 Co-authored-by: Cursor --- internal/bizdev/service.go | 8 ++++++-- internal/server/notification_reminder_loop.go | 10 ++++++++-- internal/server/server.go | 10 ++++++++++ internal/server/vault.go | 13 +++++++++++-- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index 6b6ddf8f..138d43fa 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -825,13 +825,17 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun case <-stopHB: return case <-ticker.C: - _, _ = s.docGov.HeartbeatLease(context.Background(), docID, string(docgov.LeaseHolderSquad), leaseHolderID, leaseTTL) + hbCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + _, _ = s.docGov.HeartbeatLease(hbCtx, docID, string(docgov.LeaseHolderSquad), leaseHolderID, leaseTTL) + cancel() } } }() defer func() { if leaseAcquired { - _, _ = s.docGov.ReleaseLease(context.Background(), docID, string(docgov.LeaseHolderSquad), leaseHolderID) + relCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + _, _ = s.docGov.ReleaseLease(relCtx, docID, string(docgov.LeaseHolderSquad), leaseHolderID) + cancel() if streamer := stream.FromContext(ctx); streamer != nil { streamer.EmitGovernance(stream.GovernancePayload{ DocID: docID, diff --git a/internal/server/notification_reminder_loop.go b/internal/server/notification_reminder_loop.go index 7210756d..fef61fae 100644 --- a/internal/server/notification_reminder_loop.go +++ b/internal/server/notification_reminder_loop.go @@ -12,12 +12,18 @@ func (s *Server) startNotificationReminderLoop() { if s == nil || s.notifySvc == nil || !s.notifySvc.Available() { return } + s.reminderStop = make(chan struct{}) go func() { ticker := time.NewTicker(defaultReminderPollInterval) defer ticker.Stop() s.runStageAdvanceReminders(context.Background()) - for range ticker.C { - s.runStageAdvanceReminders(context.Background()) + for { + select { + case <-s.reminderStop: + return + case <-ticker.C: + s.runStageAdvanceReminders(context.Background()) + } } }() } diff --git a/internal/server/server.go b/internal/server/server.go index ba3e5fc3..9d92073d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -77,6 +77,8 @@ type Server struct { vaultStore *artifactvault.FilesystemStore vaultPub *artifactvault.Publisher vaultPublisherStarted int32 + vaultStop chan struct{} + reminderStop chan struct{} } // NewFromRuntime attaches the HTTP adapter to an already-wired composition root. @@ -357,6 +359,14 @@ func (s *Server) Start() error { // Shutdown 优雅停机 func (s *Server) Shutdown(ctx context.Context) error { + if s.reminderStop != nil { + close(s.reminderStop) + s.reminderStop = nil + } + if s.vaultStop != nil { + close(s.vaultStop) + s.vaultStop = nil + } if s.taskRuns != nil { _ = s.taskRuns.WaitIdle(ctx) } diff --git a/internal/server/vault.go b/internal/server/vault.go index d9c1d3ee..8e4b643b 100644 --- a/internal/server/vault.go +++ b/internal/server/vault.go @@ -3,6 +3,7 @@ package server import ( "context" "fmt" + "log/slog" "net/http" "sync/atomic" "time" @@ -56,11 +57,19 @@ func (s *Server) startVaultPublisher() { if s == nil || s.vaultPub == nil || s.cfg == nil || !s.cfg.FAVPublisherEnabled { return } + s.vaultStop = make(chan struct{}) go func() { t := time.NewTicker(2 * time.Second) defer t.Stop() - for range t.C { - _ = s.vaultPub.Drain(context.Background()) + for { + select { + case <-s.vaultStop: + return + case <-t.C: + if err := s.vaultPub.Drain(context.Background()); err != nil { + slog.Warn("vault_drain_failed", "error", err) + } + } } }() atomic.StoreInt32(&s.vaultPublisherStarted, 1) From 21049607f46bb79177aa31821c15f54ec6dabd64 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 18 Sep 2026 03:44:08 +0000 Subject: [PATCH 11/16] =?UTF-8?q?fix(web):=20SSE=20=E9=87=8D=E8=BF=9E=2040?= =?UTF-8?q?1/404=20fail-fast=EF=BC=8C=E5=8D=95=E5=B8=A7=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=AE=B9=E9=94=99=EF=BC=88R-7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TaskRunHttpError 携带 status;非法 JSON 帧跳过并保留 seq。 Co-authored-by: Cursor --- web/src/api/chatStream.ts | 7 ++++++- web/src/api/taskRuns.ts | 18 +++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/web/src/api/chatStream.ts b/web/src/api/chatStream.ts index 70b921fa..255eade0 100644 --- a/web/src/api/chatStream.ts +++ b/web/src/api/chatStream.ts @@ -119,7 +119,12 @@ function parseFrame(frame: string): { event: StreamEvent | null; seq?: number } // SSE comment / heartbeat frames (": ...") have no data — ignore. if (!dataLine) return { event: null, seq }; - return { event: JSON.parse(dataLine) as StreamEvent, seq }; + try { + return { event: JSON.parse(dataLine) as StreamEvent, seq }; + } catch (err) { + console.warn('[sse] 忽略无法解析的帧:', dataLine.slice(0, 200), err); + return { event: null, seq }; + } } export function parseSSEChunk( diff --git a/web/src/api/taskRuns.ts b/web/src/api/taskRuns.ts index 68c37825..406c0717 100644 --- a/web/src/api/taskRuns.ts +++ b/web/src/api/taskRuns.ts @@ -37,6 +37,19 @@ export function sleepMs(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +export class TaskRunHttpError extends Error { + status: number; + constructor(status: number) { + super(`subscribe task run failed: HTTP ${status}`); + this.name = 'TaskRunHttpError'; + this.status = status; + } +} + +function isNonRetryableStatus(status: number): boolean { + return status === 401 || status === 404; +} + /** 单次 SSE 读取;意外 EOF 时返回 { terminal:false, lastSeq } 供上层重连。 */ export async function readTaskRunSSEOnce( runId: string, @@ -51,7 +64,7 @@ export async function readTaskRunSSEOnce( signal, }); if (!resp.ok || !resp.body) { - throw new Error(`subscribe task run failed: HTTP ${resp.status}`); + throw new TaskRunHttpError(resp.status); } const reader = resp.body.getReader(); const decoder = new TextDecoder(); @@ -113,6 +126,9 @@ export async function subscribeTaskRunEvents( // 意外 EOF:未见到 done/error,按 after_seq 重连 } catch (err) { if (signal?.aborted) return; + if (err instanceof TaskRunHttpError && isNonRetryableStatus(err.status)) { + throw err; + } if (attempt >= maxReconnects) throw err; } if (attempt >= maxReconnects) { From be9b855783cfa2bac164620d426a06b3beb41f2b Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 18 Sep 2026 03:44:08 +0000 Subject: [PATCH 12/16] =?UTF-8?q?fix(web):=20=E7=99=BB=E5=87=BA=E4=B8=BB?= =?UTF-8?q?=E5=8A=A8=20abort=20=E6=89=80=E6=9C=89=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=9C=A8=E9=80=94=E6=B5=81=EF=BC=88R-8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 abortAllSessions;logout 先中止全会话再清 session。 Co-authored-by: Cursor --- web/src/App.tsx | 18 +++++++++++------- web/src/hooks/useTaskRunStream.ts | 10 ++++++++++ web/src/hooks/useTurnRuntime.ts | 13 +++++++++++++ 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 3df7ab6c..8fca6e4d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -602,13 +602,7 @@ export default function App() { }; }, [session, sessionValidated, currentUser.id, projects]); - const handleLogout = () => { - void authClient.logout().finally(() => { - setSession(null); - setCapabilities([]); - setSessionValidated(false); - }); - }; + // handleLogout 定义在 turnRuntime/taskRunStream hook 之后,便于 abort 全会话在途流。 // 核心功能权限计算 const canCreateProject = useMemo(() => { @@ -2821,6 +2815,16 @@ export default function App() { } }; + const handleLogout = () => { + turnRuntime.abortAllSessions(); + taskRunStream.abortAllSessions(); + void authClient.logout().finally(() => { + setSession(null); + setCapabilities([]); + setSessionValidated(false); + }); + }; + // 发送消息与对话驱动双轨逻辑 const handleSendMessage = async ( text: string, diff --git a/web/src/hooks/useTaskRunStream.ts b/web/src/hooks/useTaskRunStream.ts index d919e95a..ba678491 100644 --- a/web/src/hooks/useTaskRunStream.ts +++ b/web/src/hooks/useTaskRunStream.ts @@ -89,6 +89,15 @@ export function useTaskRunStream(activeSessionId: string) { [getRuntime, notify], ); + const abortAllSessions = useCallback(() => { + for (const rt of storeRef.current.sessions.values()) { + rt.controller?.abort(); + rt.controller = null; + rt.thinking = false; + } + notify(); + }, [notify]); + return { runtime: snapshot, isThinking: snapshot.thinking, @@ -98,5 +107,6 @@ export function useTaskRunStream(activeSessionId: string) { setRunId, setController, abortSession, + abortAllSessions, }; } diff --git a/web/src/hooks/useTurnRuntime.ts b/web/src/hooks/useTurnRuntime.ts index cf66589e..7b95fc8e 100644 --- a/web/src/hooks/useTurnRuntime.ts +++ b/web/src/hooks/useTurnRuntime.ts @@ -108,6 +108,18 @@ export function useTurnRuntime(activeSessionId: string) { notify(); }, [activeSessionId, getSessionMap, notify]); + const abortAllSessions = useCallback(() => { + for (const sessionMap of storeRef.current.turns.values()) { + for (const rt of sessionMap.values()) { + if (rt.status === 'pending' || rt.status === 'streaming') { + rt.controller.abort(); + rt.status = 'cancelled'; + } + } + } + notify(); + }, [notify]); + const isSessionThinking = useCallback( (sessionId: string) => { const sessionMap = storeRef.current.turns.get(sessionId); @@ -138,6 +150,7 @@ export function useTurnRuntime(activeSessionId: string) { removeTurn, abortTurn, abortActiveSessionTurns, + abortAllSessions, isThinking, isSessionThinking, getTurn, From 4473a2ae100ce0e96f735e81d34a427c4c895ea8 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 18 Sep 2026 03:44:08 +0000 Subject: [PATCH 13/16] =?UTF-8?q?test(web):=20=E8=A1=A5=20401=20fail-fast?= =?UTF-8?q?=E3=80=81=E5=8D=95=E5=B8=A7=E5=AE=B9=E9=94=99=E3=80=81abortAllS?= =?UTF-8?q?essions=20=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- web/src/api/chatStream.test.ts | 10 +++++ web/src/api/taskRuns.test.ts | 58 +++++++++++++++++++++++++++- web/src/hooks/useTurnRuntime.test.ts | 39 +++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/web/src/api/chatStream.test.ts b/web/src/api/chatStream.test.ts index 1320dce3..5d1e2fd5 100644 --- a/web/src/api/chatStream.test.ts +++ b/web/src/api/chatStream.test.ts @@ -33,3 +33,13 @@ test('parseSSEChunk: ignores heartbeat comments and keeps last id', () => { assert.equal(events.length, 2); assert.equal(lastSeq, 2); }); + +test('parseSSEChunk: skips a single malformed frame and keeps parsing the next valid frame', () => { + const raw = + 'id: 1\nevent: delta\ndata: {not valid json\n\n' + + 'id: 2\nevent: delta\ndata: {"type":"delta","delta":"ok"}\n\n'; + const { events, lastSeq } = parseSSEChunk(raw); + assert.equal(events.length, 1); + assert.equal(events[0].delta, 'ok'); + assert.equal(lastSeq, 2); +}); diff --git a/web/src/api/taskRuns.test.ts b/web/src/api/taskRuns.test.ts index a3ef0ad4..4b8e70cd 100644 --- a/web/src/api/taskRuns.test.ts +++ b/web/src/api/taskRuns.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createTaskRun, readTaskRunSSEOnce, subscribeTaskRunEvents } from './taskRuns'; +import { createTaskRun, readTaskRunSSEOnce, subscribeTaskRunEvents, TaskRunHttpError } from './taskRuns'; import { parseSSEChunk } from './chatStream'; test('createTaskRun exposes DOC_BUSY detail without a retryable run', async () => { @@ -100,3 +100,59 @@ test('readTaskRunSSEOnce marks non-terminal EOF for reconnect', async () => { globalThis.fetch = originalFetch; } }); + +test('subscribeTaskRunEvents fails fast on 401 without retrying', async () => { + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + return new Response('', { status: 401 }); + }; + try { + await assert.rejects( + () => + subscribeTaskRunEvents('run_401', { + onEvent: () => undefined, + maxReconnects: 5, + reconnectDelayMs: 1, + sleep: async () => undefined, + }), + (err: unknown) => { + assert.ok(err instanceof TaskRunHttpError); + assert.equal(err.status, 401); + return true; + }, + ); + assert.equal(calls, 1, 'must not retry on 401'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('subscribeTaskRunEvents fails fast on 404 without retrying', async () => { + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + return new Response('', { status: 404 }); + }; + try { + await assert.rejects( + () => + subscribeTaskRunEvents('run_404', { + onEvent: () => undefined, + maxReconnects: 5, + reconnectDelayMs: 1, + sleep: async () => undefined, + }), + (err: unknown) => { + assert.ok(err instanceof TaskRunHttpError); + assert.equal(err.status, 404); + return true; + }, + ); + assert.equal(calls, 1, 'must not retry on 404'); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/web/src/hooks/useTurnRuntime.test.ts b/web/src/hooks/useTurnRuntime.test.ts index efb11b11..26b49643 100644 --- a/web/src/hooks/useTurnRuntime.test.ts +++ b/web/src/hooks/useTurnRuntime.test.ts @@ -44,3 +44,42 @@ test('turn runtime: session A stream survives session B switch', () => { assert.equal(isSessionThinking('session-A'), true); assert.equal(isSessionThinking('session-B'), true); }); + +test('abortAllSessions aborts controllers across all sessions', () => { + type TurnRuntime = { + clientTurnId: string; + sessionId: string; + status: string; + controller: { aborted: boolean; abort: () => void }; + }; + const store = new Map>(); + + const makeController = () => { + const c = { aborted: false, abort() { c.aborted = true; } }; + return c; + }; + + const register = (rt: TurnRuntime) => { + if (!store.has(rt.sessionId)) store.set(rt.sessionId, new Map()); + store.get(rt.sessionId)!.set(rt.clientTurnId, rt); + }; + + const abortAllSessions = () => { + for (const sessionMap of store.values()) { + for (const rt of sessionMap.values()) { + if (rt.status === 'pending' || rt.status === 'streaming') { + rt.controller.abort(); + rt.status = 'cancelled'; + } + } + } + }; + + const a = makeController(); + const b = makeController(); + register({ clientTurnId: 't1', sessionId: 's1', status: 'streaming', controller: a }); + register({ clientTurnId: 't2', sessionId: 's2', status: 'pending', controller: b }); + abortAllSessions(); + assert.equal(a.aborted, true); + assert.equal(b.aborted, true); +}); From c1ea8f156ce6959a15d32a66b1c8ae384c891bb8 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 18 Sep 2026 03:46:21 +0000 Subject: [PATCH 14/16] =?UTF-8?q?fix(server):=20=E6=B8=85=E9=99=A4=20NewSe?= =?UTF-8?q?ssion=20ProjectID=3D1=20=E5=8D=A0=E4=BD=8D=E4=BB=A5=E5=85=8D?= =?UTF-8?q?=E8=AF=AF=E8=A7=A6=E5=8F=91=E9=A1=B9=E7=9B=AE=E9=89=B4=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 非项目会话创建时 ProjectID 归零,避免跨用户负例在 PG 上被 ERR_FORBIDDEN 误挡。 Co-authored-by: Cursor --- internal/server/server.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index 9d92073d..bf569257 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -573,8 +573,13 @@ func (s *Server) getOrCreateSession( } } - // 新建会话时立即绑定调用者。NewSession 历史占位 UserID=1 不能当作已绑定归属。 - if callerUserID > 0 && (restored == nil || restored.UserID <= 0) { + // 新建会话时立即绑定调用者。NewSession 历史占位 UserID=1/ProjectID=1 不能当作已绑定归属。 + if restored == nil { + sess.ProjectID = 0 + if callerUserID > 0 { + sess.UserID = callerUserID + } + } else if restored.UserID <= 0 && callerUserID > 0 { sess.UserID = callerUserID } From e8d2d8654253e500c66c54aed9badc3d1e4d628f Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 18 Sep 2026 03:46:22 +0000 Subject: [PATCH 15/16] =?UTF-8?q?test(docrevision):=20hydrate=20=E8=B4=9F?= =?UTF-8?q?=E4=BE=8B=E5=86=99=E5=85=A5=E7=B1=BB=E5=9E=8B=E9=94=99=E8=AF=AF?= =?UTF-8?q?=20JSONB=20=E5=B9=B6=E5=85=88=E8=90=BD=20blob?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../pg_store_hydrate_corrupt_test.go | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/internal/docrevision/pg_store_hydrate_corrupt_test.go b/internal/docrevision/pg_store_hydrate_corrupt_test.go index 22506950..74b0c674 100644 --- a/internal/docrevision/pg_store_hydrate_corrupt_test.go +++ b/internal/docrevision/pg_store_hydrate_corrupt_test.go @@ -4,6 +4,7 @@ import ( "context" "strings" "testing" + "time" "chat-agent-system/internal/db" "chat-agent-system/internal/docrevision" @@ -13,16 +14,20 @@ import ( func TestHydrateDocLocked_CorruptJSONBFailsExplicitly_PG(t *testing.T) { dsn := db.SkipIfNoTestDatabaseURL(t) - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() pool, err := pgxpool.New(ctx, dsn) if err != nil { t.Fatal(err) } defer pool.Close() + if err := db.RunMigrations(ctx, pool, db.ResolveMigrationsDir()); err != nil { + t.Fatalf("migrate: %v", err) + } var n int if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM information_schema.tables WHERE table_name='document_snapshots'`).Scan(&n); err != nil || n == 0 { - t.Skip("document_snapshots table missing; migrate required") + t.Skip("document_snapshots table missing after migrate") } docID := "doc_hydrate_corrupt_jsonb" @@ -32,35 +37,34 @@ func TestHydrateDocLocked_CorruptJSONBFailsExplicitly_PG(t *testing.T) { _, _ = pool.Exec(ctx, `DELETE FROM document_versions WHERE doc_id=$1`, docID) _, _ = pool.Exec(ctx, `DELETE FROM document_snapshots WHERE doc_id=$1`, docID) + sha := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // 64 chars + _, err = pool.Exec(ctx, ` + INSERT INTO document_content_blobs (content_sha256, content_markdown, byte_size, created_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (content_sha256) DO NOTHING + `, sha, "# corrupt fixture\n", int64(18)) + if err != nil { + t.Fatalf("insert content blob: %v", err) + } + + // jsonb 列拒绝非法 JSON;写入合法但类型不匹配的值,使 Go Unmarshal 到 map/struct 失败。 _, err = pool.Exec(ctx, ` INSERT INTO document_snapshots ( doc_id, content_sha256, parent_snapshot_id, block_map, source_kind, actor_user_id, actor_ref, actor_snapshot, source_run_id, created_at ) VALUES ( - $1, 'sha_corrupt_test', NULL, '{not-valid-json'::bytea, 'human', - 1, 'tester', '{}'::jsonb, NULL, now() + $1, $2, NULL, '"not-an-object"'::jsonb, 'human', + 1, 'tester', '{}'::jsonb, '', now() ) - `, docID) + `, docID, sha) if err != nil { - // Some schemas store block_map as jsonb (rejects invalid at insert). Fall back to casting via text. - _, err = pool.Exec(ctx, ` - INSERT INTO document_snapshots ( - doc_id, content_sha256, parent_snapshot_id, block_map, source_kind, - actor_user_id, actor_ref, actor_snapshot, source_run_id, created_at - ) VALUES ( - $1, 'sha_corrupt_test', NULL, convert_to('{not-valid-json', 'UTF8'), 'human', - 1, 'tester', '{}'::jsonb, NULL, now() - ) - `, docID) - if err != nil { - t.Skipf("cannot insert corrupt block_map for schema: %v", err) - } + t.Fatalf("insert typed-wrong block_map: %v", err) } svc := docrevision.NewService(pool) _, err = svc.GetRevisionState(ctx, docID) if err == nil { - t.Fatal("expected hydrate error for corrupt block_map JSONB") + t.Fatal("expected hydrate error for type-mismatched block_map JSONB") } if !strings.Contains(err.Error(), "hydrateDocLocked") || !strings.Contains(err.Error(), "block_map") { t.Fatalf("expected explicit hydrate/block_map error, got: %v", err) From e4c53a4ccde2927c5ba473a4c2e943d40552c3bc Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 18 Sep 2026 03:47:46 +0000 Subject: [PATCH 16/16] =?UTF-8?q?docs(ops):=20Wave4=20R-1~R-8=20=E6=94=B6?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 归档 test-report 与 assets;CURRENT/remediation 标 Wave 4 done;勾选跨用户会话挂靠负例。 Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 5 +- .../2026-09-18-wave4-hardening-r1-r8.md | 49 ++ ...26-09-17-system-health-remediation-plan.md | 8 +- ...09-18-wave4-hardening-r1-r8-test-report.md | 118 ++++ .../go-build.txt | 1 + .../go-race.txt | 3 + .../go-test-short.txt | 68 +++ .../go-vet.txt | 1 + .../hydrate-corrupt-pg.txt | 57 ++ .../npm-build.txt | 237 ++++++++ .../npm-test.txt | 517 ++++++++++++++++++ .../ownership-pg.txt | 31 ++ 12 files changed, 1089 insertions(+), 6 deletions(-) create mode 100644 .doc/ops/short-term/2026-09-18-wave4-hardening-r1-r8.md create mode 100644 .doc/test-reports/2026-09-18-wave4-hardening-r1-r8-test-report.md create mode 100644 .doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-build.txt create mode 100644 .doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-race.txt create mode 100644 .doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-test-short.txt create mode 100644 .doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-vet.txt create mode 100644 .doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/hydrate-corrupt-pg.txt create mode 100644 .doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/npm-build.txt create mode 100644 .doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/npm-test.txt create mode 100644 .doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/ownership-pg.txt diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 20852961..f10640a3 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,7 +5,7 @@ ## 焦点 -1. **系统体检整改**:Wave 0–3 **done**;下一波 Wave 4(隐患加固 R-1~R-8) +1. **系统体检整改**:Wave 0–4 **done**;下一波 Wave 5(死代码决策 X-1~X-5) 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-Auto | 2026-09-18-wave4-hardening-r1-r8 | fix/wave4-hardening-r1-r8 | - | hardening | done | | Cursor-Auto | 2026-09-18-wave3-test-debt-blindspots | fix/wave3-test-debt-blindspots | - | test-debt | done | | Cursor-Auto | 2026-09-18-wave2-config-flag-align | fix/wave2-config-flag-align | - | infra | done | | Cursor | 2026-09-18-wave1-main-env-fix | fix/wave1-main-env-fix | - | infra | done | @@ -135,7 +136,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | 2026-09-18 | Wave 3 test-debt 已合入(见 [test-report](../test-reports/2026-09-18-wave3-test-debt-blindspots-test-report.md)) | - | +| Cursor-Auto | merging | 2026-09-18 | Wave 4 R-1~R-8 → 本地 `.worktrees/dev-rdev-merge` | - | **phase**: `idle` | `merging` | `smoke` | `e2e` diff --git a/.doc/ops/short-term/2026-09-18-wave4-hardening-r1-r8.md b/.doc/ops/short-term/2026-09-18-wave4-hardening-r1-r8.md new file mode 100644 index 00000000..d543aa6e --- /dev/null +++ b/.doc/ops/short-term/2026-09-18-wave4-hardening-r1-r8.md @@ -0,0 +1,49 @@ +--- +title: "Wave 4:隐患加固(R-1~R-8)" +status: done +created: 2026-09-18 +updated: 2026-09-18 +owner: Cursor-Auto +branch: fix/wave4-hardening-r1-r8 +worktree: "-" +mutex: hardening +--- + +# Wave 4:隐患加固(R-1~R-8) + +## 目标 + +按 Wave 4 Sonnet 计划默认决策关闭体检高风险隐患 R-1~R-8。 + +## 范围 + +- R-1~R-8 全项(含 D-1 扩大范围:upsert + COALESCE) + +## 非目标 + +- D-4/D-5;不升 tier;不接线 REPLACE;不改 FAV;项目 143 只读;不 push + +## 步骤 + +1. [x] R-1c/R-1a/R-1b + Memory/PG 负例 +2. [x] R-2 rows.Err() +3. [x] R-3 RWMutex / R-4 memTurns / R-5 hydrate / R-6 Shutdown +4. [x] R-7/R-8 前端 + 单测 +5. [x] 全量验收 + test-report + 合入本地 `.worktrees/dev-rdev-merge` dev + +## 验收标准 + +- [x] R-1~R-8 按计划验收项 PASS +- [x] `go vet` / `go build` / `go test -short ./...` / `npm test` / `npm run build` +- [x] 相关 `-race` 绿 +- [x] 文档闭环 + +## 相关链接 + +- Remediation: [.doc/remediation/2026-09-17-system-health-remediation-plan.md](../../remediation/2026-09-17-system-health-remediation-plan.md) +- Test report: [.doc/test-reports/2026-09-18-wave4-hardening-r1-r8-test-report.md](../../test-reports/2026-09-18-wave4-hardening-r1-r8-test-report.md) +- Assets: [.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/](../../test-reports/assets/2026-09-18-wave4-hardening-r1-r8/) + +## 证据 / 备注 + +基线 tip:`32932e4`。合入本地 `dev` 见 merge commit。 diff --git a/.doc/remediation/2026-09-17-system-health-remediation-plan.md b/.doc/remediation/2026-09-17-system-health-remediation-plan.md index f524d598..5786dfd1 100644 --- a/.doc/remediation/2026-09-17-system-health-remediation-plan.md +++ b/.doc/remediation/2026-09-17-system-health-remediation-plan.md @@ -2,13 +2,13 @@ title: "系统体检整改与完善实施方案(2026-09-17)" status: planned created: 2026-09-17 -updated: 2026-09-17 +updated: 2026-09-18 --- # 系统体检整改与完善实施方案 > 配套审计:[../audit/2026-09-17-system-health-check.md](../audit/2026-09-17-system-health-check.md)(编号 B-x 确认 bug、R-x 隐患、E-x 环境缺陷、T-x 任务、X-x 死代码、S-x 测试盲区、D-x 文档漂移)。 -> 本方案只列计划与验收,**不含实施**。所有波次遵守 AGENTS.md §6:从 dev 开 `fix/*`/`feature/*`,合回 dev 占集成槽位;三大主干禁止直接 commit。 +> Wave 0–4 已实施收口;后续 Wave 5+ 仍按本方案推进。 ## 背景 @@ -31,7 +31,7 @@ updated: 2026-09-17 | **Wave 1:main 环境修正(critical)** | E-1:app/events 统一 `ENV=main`。E-2:app `depends_on events: service_healthy`(events 同步迁移后再启 app)。E-5:`Env==""` 不再视同 dev。附带:events 补齐 `validateProductionSecrets` 所需口令以免 Restarting | **done**(2026-09-18):[test-report](../test-reports/2026-09-18-wave1-main-env-fix-test-report.md);main health `env=main` + `schema_version=52`;consumer modes 仍 off | | **Wave 2:配置与灰度入口对齐** | E-3:三环境声明 `MULTI_INTENT_PLAN_TIER`+U9–U12(legacy/false);A-4:test/main 补 SHADOW/NARRATIVE。E-4:删除 `CURSOR_RUNNER_URL`+cursor-agent。E-6:main `SEED_DEMO_USERS=false`。E-7:非法 `INTENT_V2_DECISION_TIER`→`none`。E-9:评估不改 initdb | **done**(2026-09-18):[test-report](../test-reports/2026-09-18-wave2-config-flag-align-test-report.md);三环境 env 抽检 + health/ready PASS | | **Wave 3:测试债与盲区** | S-1:隔离 PG(5436) 跑 `TestReconcile_LegacyRepair_PG`。S-2:删 taskstream 死跳过;plan_owner 重定向说明。S-4:每 spec npm 脚本;退役 `document-revision.spec`(消灭 DOCREV_E2E_BASE_URL)。S-5:`projectSettingsError` 复用到 members/freeform/taskRuns/chatSessions | **done**(2026-09-18):[test-report](../test-reports/2026-09-18-wave3-test-debt-blindspots-test-report.md);playwright list 29/8;reconcile PG PASS | -| **Wave 4:隐患加固(可与 Wave 0–3 并行认领,mutex 不冲突时)** | R-1:`getOrCreateSession` 增 owner 绑定校验(session 创建时记录 owner,后续请求校验,不匹配 403)。R-2:18 处 `rows.Next()` 循环补 `rows.Err()` 检查。R-3:dispatcher Handlers 读路径加 RLock(或注释冻结「仅启动期注册」并加 archtest 约束)。R-4:`memTurns` 加 TTL/容量淘汰。R-5:pg_store JSONB 反序列化错误改为显式报错。R-6:后台 ticker 纳入 Shutdown;vault Drain 错误上报告警。R-7/R-8:SSE 重连 401/404 fail-fast、逐帧容错;登出 abort 在途流 | R-1 跨用户会话负例测试 PASS(补 PG + 内存双实现);R-2 `go vet`+单测;R-7 前端单测模拟 401 立即抛出 | +| **Wave 4:隐患加固(可与 Wave 0–3 并行认领,mutex 不冲突时)** | R-1:`getOrCreateSession` 增 owner 绑定校验(session 创建时记录 owner,后续请求校验,不匹配 403)。R-2:18 处 `rows.Next()` 循环补 `rows.Err()` 检查。R-3:dispatcher Handlers 读路径加 RLock(或注释冻结「仅启动期注册」并加 archtest 约束)。R-4:`memTurns` 加 TTL/容量淘汰。R-5:pg_store JSONB 反序列化错误改为显式报错。R-6:后台 ticker 纳入 Shutdown;vault Drain 错误上报告警。R-7/R-8:SSE 重连 401/404 fail-fast、逐帧容错;登出 abort 在途流 | **done**(2026-09-18):[test-report](../test-reports/2026-09-18-wave4-hardening-r1-r8-test-report.md);Memory+PG 跨用户负例 PASS;`go test -short` / `npm test` 全绿 | | **Wave 5:死代码与机制收口(需逐项决策,不擅自删)** | X-1:`SEMANTIC_FALLBACK_ENABLED` 删除或接线(先决策语义兜底是否还需要)。X-2:`SEMANTIC_SUMMARY_REPLACE` 维持未接线但补注释说明为刻意封存(与红线一致)。X-3:00048 三表消费方接线评估(并入 W5b SWBOOT-10 或标记 deferred)。X-4:system_settings 6 键逐一接消费方或从白名单移除(系统管理台二期范围)。X-5:前端死组件/死 API 删除(DocumentNotificationList、WorkflowPrerequisiteBanner、SettingsModal、activateWorkflow、saveMessages)或明确保留理由 | 每项有决策记录(ADR 或 short-term 决议段);删除后 `npm run build`/`go build` 全绿 | | **Wave 6:文档漂移修复** | D-1:PU8 四份契约文件状态改 done 并归档。D-2:modular-monolith ADR 状态更新为 accepted。D-3:两个 INDEX 末行已在本审计修正(复核即可)。D-4:handoff 回标已被覆盖。D-5:backlog D3/D7 同步最新状态。D-6:PROJECT.md §9「剩余 blocker」段与 gap-matrix 对齐或删除 | 漂移清单逐项回链;`.doc/audit/INDEX.md`、`remediation/INDEX.md` 与 FEATURE-MATRIX 三方口径一致 | | **Wave 7:既有 roadmap 收口(不改优先级,仅确认编排)** | T1/W5:SWBOOT-10 安全评审后实施(保持 fail-closed 直至授权)。T1/W6:FAV live 放量 staging→artifact→publication,每级 ≥7 天观察窗,`FAV_DEGRADED_READ=false` 不动。T1/W7:D7 待合规签字后按 ADR 整改。T2:Control Center 前端 SPA 七页人工走查。T4:首次加载性能 U1/U3–U7。T5/T6/T8:backlog 类按收益触发 | 各项沿用其原有关闭条件(见 FEATURE-MATRIX 剩余工作列);FAV/D7/tier 升级保持「需用户显式授权」红线 | @@ -44,7 +44,7 @@ updated: 2026-09-17 - [x] Wave 2 配置声明对齐:三环境 compose diff 复核 + 容器 env 抽检 + go test 全绿(E-3/E-4/E-6/E-7 完成;E-9 评估结论已记录,不改代码)([报告](../test-reports/2026-09-18-wave2-config-flag-align-test-report.md)) - [x] `TestReconcile_LegacyRepair_PG` 报告归档,gap-matrix 测试债行关闭(Wave 3) - [x] `playwright test --list` 与门禁文档一致(Wave 3) -- [ ] 跨用户会话挂靠负例测试入库(Wave 4) +- [x] 跨用户会话挂靠负例测试入库(Wave 4) - [ ] X-1~X-5 每项有决策记录(Wave 5) - [ ] D-1~D-6 全部回链关闭(Wave 6) - [x] 不触碰红线:FAV_DEGRADED_READ=false、MULTI_INTENT_PLAN_TIER 默认 legacy、SEMANTIC_SUMMARY_REPLACE 不接线、项目 143 只读、禁止整支 merge FAV diff --git a/.doc/test-reports/2026-09-18-wave4-hardening-r1-r8-test-report.md b/.doc/test-reports/2026-09-18-wave4-hardening-r1-r8-test-report.md new file mode 100644 index 00000000..18e9595d --- /dev/null +++ b/.doc/test-reports/2026-09-18-wave4-hardening-r1-r8-test-report.md @@ -0,0 +1,118 @@ +# Wave 4:隐患加固(R-1~R-8)测试报告 + +## 一、 测试背景 (Background) + +系统体检(2026-09-17)确认高风险隐患 R-1~R-8:会话跨用户挂靠、`rows.Err()` 漏检、dispatcher 无锁读、`memTurns` 全局只增不删、JSONB 静默零值化、后台 ticker 无 Shutdown、SSE 401/404 盲目重连与登出未 abort 在途流。Wave 0–3 已合入后按 Sonnet 计划默认决策实施本波。 + +## 二、 测试目标 (Test Objectives) + +- R-1:跨用户 chat / chat/stream / chat/sessions 全部 403;受害者续用 200;Memory + PG 双实现 +- R-2:18 文件补齐 `rows.Err()`;B 类 slog.Warn +- R-3:`-race` 下 events 无竞态 +- R-4:实例隔离 + TTL/容量淘汰测试 PASS +- R-5:损坏/类型错误 JSONB 显式报错 +- R-6:Shutdown 可停止 reminder/vault;Drain 可告警;bizdev 心跳超时 +- R-7/R-8:401/404 fail-fast(calls===1);单帧容错;abortAllSessions +- 全量:`go vet` / `go build` / `go test -short` / `npm test` / `npm run build` + +## 三、 测试方案与环境 (Test Plan & Environment) + +- 分支:`fix/wave4-hardening-r1-r8` ← 本地 tip `32932e4` +- 隔离 PG:`pgvector/pgvector:pg16` → `127.0.0.1:5436`,测完 `unset TEST_DATABASE_URL` + `docker stop wave4-pg` +- OS:Linux;Go 模块本仓;前端 Vite/Node test runner +- 证据:`.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/` + +## 四、 预期结果 (Expected Results) + +| 项 | 预期 | +|----|------| +| R-1 Memory/PG | attacker 403 SESSION_FORBIDDEN;stream 非 SSE;victim 续用 OK | +| R-2 | `go vet` 绿;18 文件循环后可见 `rows.Err()` | +| R-3 | `go test -race -short ./internal/events/...` 绿 | +| R-4 | ChatTurn 四测 PASS;无全局 `memTurns` 重置 | +| R-5 | hydrate 错误含 `hydrateDocLocked` + `block_map` | +| R-7 | 401/404 各仅 1 次 fetch;畸形帧跳过后仍解析下一帧 | +| 全量 | short/npm/build EXIT:0 | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 R-1 跨用户会话挂靠 + +**输入**:受害者建立 `sess-hijack-*` 后攻击者同 session_id 调 chat / stream / upsert。 + +**实际输出**(`ownership-pg.txt`): + +``` +--- PASS: TestChatSessionOwnership_CrossUserHijackDenied_PG +--- PASS: TestChatSessionOwnership_CrossUserHijackDenied_Memory +``` + +**判定**:PASS(附带清除 NewSession `ProjectID=1` 占位,避免 PG 误走项目鉴权) + +### 5.2 R-2 rows.Err() + +**实际输出**:`go-vet.txt` EXIT:0;静态补齐 18 文件。 + +**判定**:PASS + +### 5.3 R-3 / R-4 race + +**实际输出**(`go-race.txt`): + +``` +ok chat-agent-system/internal/events +ok chat-agent-system/internal/ctxmgr +EXIT:0 +``` + +含 `TestAcquireChatTurn_StaleEntriesPruned` PASS。 + +**判定**:PASS + +### 5.4 R-5 hydrate JSONB + +**实际输出**(`hydrate-corrupt-pg.txt`): + +``` +--- PASS: TestHydrateDocLocked_CorruptJSONBFailsExplicitly_PG +``` + +**判定**:PASS + +### 5.5 R-6 + +代码级:`Shutdown` close `reminderStop`/`vaultStop`;vault Drain → `vault_drain_failed`;bizdev 心跳/释放 10s timeout。随 `go test -short ./internal/server/... ./internal/bizdev/...` 回归。 + +**判定**:PASS + +### 5.6 R-7 / R-8 前端 + +**实际输出**(`npm-test.txt`):tests 406 pass;vitest 32 pass;含 401/404 fail-fast 与畸形帧容错、abortAllSessions。 + +**判定**:PASS + +### 5.7 全量回归 + +- `go-vet.txt` / `go-build.txt` / `go-test-short.txt`:EXIT:0 +- `npm-build.txt`:EXIT:0 + +**判定**:PASS + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 命令 | 结果 | +|------|------| +| `go vet ./...` | PASS | +| `go build ./...` | PASS | +| `go test -short -count=1 ./...` | PASS | +| `go test -race -short ./internal/events/... ./internal/ctxmgr/...` | PASS | +| `cd web && npm test` | PASS(406 + 32) | +| `cd web && npm run build` | PASS | +| 隔离 PG R-1/R-5 | PASS 后销毁容器 | + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- Wave 4 R-1~R-8 达到合入本地 `dev` 标准。 +- 未改 D-4/D-5(`handleGetProject` 鉴权、`handleRunTaskStream` 归属);记入后续 backlog。 +- 红线自查:未触碰 FAV modes / MULTI_INTENT_PLAN_TIER / SEMANTIC_SUMMARY_REPLACE / 项目 143。 +- 下一波:Wave 5 死代码决策(X-1~X-5)。 diff --git a/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-build.txt b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-build.txt new file mode 100644 index 00000000..cb40029a --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-build.txt @@ -0,0 +1 @@ +EXIT:0 diff --git a/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-race.txt b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-race.txt new file mode 100644 index 00000000..bfa8e4b7 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-race.txt @@ -0,0 +1,3 @@ +ok chat-agent-system/internal/events 1.020s +ok chat-agent-system/internal/ctxmgr 1.137s +EXIT:0 diff --git a/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-test-short.txt b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-test-short.txt new file mode 100644 index 00000000..eb566e3d --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-test-short.txt @@ -0,0 +1,68 @@ +? chat-agent-system/cmd/artifact-vault [no test files] +ok chat-agent-system/cmd/bizdev-cli 2.927s +? chat-agent-system/cmd/dataset [no test files] +ok chat-agent-system/cmd/document-content-reconcile 0.007s +? chat-agent-system/cmd/document-revision-backfill [no test files] +? chat-agent-system/cmd/engineering-dashboard [no test files] +? chat-agent-system/cmd/eventmode [no test files] +? chat-agent-system/cmd/events [no test files] +? chat-agent-system/cmd/recategorize [no test files] +? chat-agent-system/cmd/server [no test files] +ok chat-agent-system/internal/agent 3.645s +ok chat-agent-system/internal/app 0.018s +ok chat-agent-system/internal/app/chatflow 0.006s +ok chat-agent-system/internal/app/docadvance 0.003s +ok chat-agent-system/internal/app/docflow 0.005s +ok chat-agent-system/internal/app/docnotify 0.003s +ok chat-agent-system/internal/app/docproject 0.003s +ok chat-agent-system/internal/app/fav 0.004s +ok chat-agent-system/internal/app/intentflow 0.013s +ok chat-agent-system/internal/app/stageflow 0.006s +ok chat-agent-system/internal/app/uow 0.006s +ok chat-agent-system/internal/archtest 0.177s +ok chat-agent-system/internal/artifactvault 0.006s +ok chat-agent-system/internal/auth 0.988s +ok chat-agent-system/internal/authz 0.003s +ok chat-agent-system/internal/bizdev 0.149s +ok chat-agent-system/internal/chat 0.015s +ok chat-agent-system/internal/config 0.005s +ok chat-agent-system/internal/coordinator 0.011s +ok chat-agent-system/internal/ctxmgr 0.085s +ok chat-agent-system/internal/dataset 0.005s +ok chat-agent-system/internal/db 0.004s +ok chat-agent-system/internal/doccontent 0.002s +ok chat-agent-system/internal/docduty 0.004s +ok chat-agent-system/internal/docgov 0.026s +ok chat-agent-system/internal/docquery 0.004s +ok chat-agent-system/internal/docrevision 0.008s +ok chat-agent-system/internal/engineeringdashboard 0.009s +ok chat-agent-system/internal/events 0.007s +ok chat-agent-system/internal/freedoc 0.026s +ok chat-agent-system/internal/fsutil 0.003s +ok chat-agent-system/internal/httpx 0.003s +ok chat-agent-system/internal/intent 0.023s +ok chat-agent-system/internal/kb 0.009s +ok chat-agent-system/internal/maturity 0.002s +ok chat-agent-system/internal/notification 0.074s +? chat-agent-system/internal/observability [no test files] +ok chat-agent-system/internal/projectcontext 0.005s +ok chat-agent-system/internal/projectinstance 0.005s +ok chat-agent-system/internal/projectworkflow 0.003s +ok chat-agent-system/internal/quote 0.009s +ok chat-agent-system/internal/replyexport 0.004s +ok chat-agent-system/internal/server 14.127s +ok chat-agent-system/internal/squad 0.029s +ok chat-agent-system/internal/storage 0.012s +ok chat-agent-system/internal/stream 0.003s +ok chat-agent-system/internal/stream/transport 0.002s +ok chat-agent-system/internal/swboot 0.002s +ok chat-agent-system/internal/sysadmin 0.003s +ok chat-agent-system/internal/taskrun 0.171s +ok chat-agent-system/internal/template 0.934s +ok chat-agent-system/internal/upload 0.002s +? chat-agent-system/internal/workflow [no test files] +ok chat-agent-system/internal/workflow/runtime 0.003s +ok chat-agent-system/internal/workflowdef 0.004s +ok chat-agent-system/internal/workspace 0.003s +? chat-agent-system/templates [no test files] +EXIT:0 diff --git a/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-vet.txt b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-vet.txt new file mode 100644 index 00000000..cb40029a --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/go-vet.txt @@ -0,0 +1 @@ +EXIT:0 diff --git a/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/hydrate-corrupt-pg.txt b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/hydrate-corrupt-pg.txt new file mode 100644 index 00000000..e9ad30c2 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/hydrate-corrupt-pg.txt @@ -0,0 +1,57 @@ +=== RUN TestHydrateDocLocked_CorruptJSONBFailsExplicitly_PG +2026/09/18 03:46:15 OK 00001_baseline.sql (51.97ms) +2026/09/18 03:46:15 OK 00002_2026-09-09-doc-edit-lease.sql (1.45ms) +2026/09/18 03:46:15 OK 00003_2026-09-09-uq-context-memory-chunk-hash.sql (1.77ms) +2026/09/18 03:46:15 OK 00004_2026-09-10-chat-daily-digests-and-search.sql (514.9µs) +2026/09/18 03:46:15 OK 00005_2026-09-10-chat-messages-day-window.sql (742.04µs) +2026/09/18 03:46:15 OK 00006_2026-09-11-chat-read-positions.sql (343.55µs) +2026/09/18 03:46:15 OK 00007_2026-09-11-chat-turns.sql (352.5µs) +2026/09/18 03:46:15 OK 00008_2026-09-11-freeform-documents.sql (6.79ms) +2026/09/18 03:46:15 OK 00009_2026-09-11-message-export-policy.sql (2.83ms) +2026/09/18 03:46:15 OK 00010_2026-09-11-project-workflows-and-lineage.sql (12.01ms) +2026/09/18 03:46:15 OK 00011_2026-09-11-seed-ten-projects.sql (5.09ms) +2026/09/18 03:46:15 OK 00012_2026-09-11-seed-ten-projects-chat.sql (31.36ms) +2026/09/18 03:46:15 OK 00013_2026-09-11-task-run-lineage.sql (1.79ms) +2026/09/18 03:46:15 OK 00014_project_members.sql (4.18ms) +2026/09/18 03:46:15 OK 00015_2026-09-12-iam-core.sql (20.02ms) +2026/09/18 03:46:15 OK 00016_2026-09-12-workflow-definitions-and-templates.sql (14.73ms) +2026/09/18 03:46:15 OK 00017_2026-09-12-project-workflow-instances.sql (14.29ms) +2026/09/18 03:46:15 OK 00018_2026-09-12-outbox-notifications-stage-advance.sql (7.94ms) +2026/09/18 03:46:15 OK 00019_2026-09-12-task-runs-events.sql (5.24ms) +2026/09/18 03:46:15 OK 00020_2026-09-12-task-run-worker-lease.sql (1.08ms) +2026/09/18 03:46:15 OK 00021_2026-09-12-iam-hardening.sql (2.36ms) +2026/09/18 03:46:15 OK 00022_2026-09-12-workflow-schema-v2.sql (1.7ms) +2026/09/18 03:46:15 OK 00023_2026-09-12-instance-integrity.sql (927.36µs) +2026/09/18 03:46:15 OK 00024_2026-09-12-workflow-catalog-governance.sql (5.79ms) +2026/09/18 03:46:15 OK 00025_2026-09-13-sync-seed-sequences.sql (829.13µs) +2026/09/18 03:46:15 OK 00026_2026-09-13-chat-message-scope.sql (2.31ms) +2026/09/18 03:46:15 OK 00027_2026-09-13-chat-message-scope-backfill.sql (1.92ms) +2026/09/18 03:46:15 OK 00028_2026-09-13-document-revisions.sql (14.56ms) +2026/09/18 03:46:15 OK 00029_2026-09-13-freeform-client-key.sql (1.25ms) +2026/09/18 03:46:15 OK 00030_2026-09-13-task-run-pending-author-confirmation.sql (1.17ms) +2026/09/18 03:46:15 OK 00031_2026-09-13-project-member-document-duty.sql (2.06ms) +2026/09/18 03:46:15 OK 00032_2026-09-14-document-revision-migration-repair.sql (654.16µs) +2026/09/18 03:46:15 OK 00033_2026-09-14-task-run-blocked-pending-governance.sql (437.41µs) +2026/09/18 03:46:15 OK 00034_2026-09-14-workspace-lease-fencing.sql (482.65µs) +2026/09/18 03:46:15 OK 00035_2026-09-14-durable-event-delivery.sql (5.25ms) +2026/09/18 03:46:15 OK 00036_2026-09-14-final-artifact-vault.sql (9.3ms) +2026/09/18 03:46:15 OK 00037_2026-09-15-task-run-manifests.sql (2.77ms) +2026/09/18 03:46:15 OK 00038_2026-09-15-dataset-lifecycle.sql (7.76ms) +2026/09/18 03:46:15 OK 00039_2026-09-15-event-inventory-and-principals.sql (5.63ms) +2026/09/18 03:46:15 OK 00040_2026-09-15-event-dispatcher.sql (6.92ms) +2026/09/18 03:46:15 OK 00041_2026-09-15-notification-visible-outcome.sql (903.88µs) +2026/09/18 03:46:15 OK 00042_2026-09-15-workflow-advance-outcome.sql (879.69µs) +2026/09/18 03:46:15 OK 00043_2026-09-15-projection-outcome.sql (2.04ms) +2026/09/18 03:46:15 OK 00044_2026-09-15-intent-plans.sql (9.2ms) +2026/09/18 03:46:15 OK 00045_2026-09-16-intent-plan-projection.sql (3.43ms) +2026/09/18 03:46:15 OK 00046_2026-09-16-retire-demo-ten-projects.sql (1.13ms) +2026/09/18 03:46:15 OK 00047_2026-09-16-retire-demo-ten-project-sessions.sql (404.49µs) +2026/09/18 03:46:15 OK 00048_2026-09-16-software-project-bootstrap-fields.sql (12.43ms) +2026/09/18 03:46:15 OK 00049_2026-09-17-system-settings.sql (1.54ms) +2026/09/18 03:46:15 OK 00050_2026-09-17-workflow-author-only-policy.sql (2.64ms) +2026/09/18 03:46:15 OK 00051_2026-09-17-activation-token-author-only-revision.sql (488.28µs) +2026/09/18 03:46:15 OK 00052_2026-09-17-document-duty-locked.sql (974.73µs) +2026/09/18 03:46:15 goose: successfully migrated database to version: 52 +--- PASS: TestHydrateDocLocked_CorruptJSONBFailsExplicitly_PG (0.32s) +PASS +ok chat-agent-system/internal/docrevision 0.321s diff --git a/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/npm-build.txt b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/npm-build.txt new file mode 100644 index 00000000..76a97fa0 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/npm-build.txt @@ -0,0 +1,237 @@ + +> shuimu-workbench@1.0.0 build +> tsc && vite build && node scripts/precompress-dist.mjs + +vite v7.3.6 building client environment for production... +transforming... +✓ 4123 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html 0.72 kB │ gzip: 0.45 kB +dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff 4.42 kB +dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 4.93 kB +dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 5.21 kB +dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB +dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff 5.98 kB +dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff 6.19 kB +dist/assets/KaTeX_Size1-Regular-C195tn64.woff 6.50 kB +dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 6.91 kB +dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 6.91 kB +dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf 7.59 kB +dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff 7.66 kB +dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff 7.72 kB +dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 9.64 kB +dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 10.34 kB +dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf 10.36 kB +dist/assets/KaTeX_Script-Regular-D5yQViql.woff 10.59 kB +dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 11.32 kB +dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 11.35 kB +dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf 11.51 kB +dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 12.03 kB +dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 12.22 kB +dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf 12.23 kB +dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff 12.32 kB +dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf 12.34 kB +dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf 12.37 kB +dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff 13.21 kB +dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff 13.30 kB +dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 13.57 kB +dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff 14.11 kB +dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff 14.41 kB +dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff 16.03 kB +dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 16.40 kB +dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 16.44 kB +dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf 16.65 kB +dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 16.78 kB +dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 16.99 kB +dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff 18.67 kB +dist/assets/KaTeX_Math-Italic-DA0__PXp.woff 18.75 kB +dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff 19.41 kB +dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf 19.44 kB +dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf 19.57 kB +dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf 19.58 kB +dist/assets/KaTeX_Main-Italic-BMLOBm91.woff 19.68 kB +dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf 22.36 kB +dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf 24.50 kB +dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 25.32 kB +dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 26.27 kB +dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf 27.56 kB +dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 28.08 kB +dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff 29.91 kB +dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff 30.77 kB +dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf 31.20 kB +dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf 31.31 kB +dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf 32.97 kB +dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff 33.52 kB +dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf 33.58 kB +dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf 51.34 kB +dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf 53.58 kB +dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf 63.63 kB +dist/assets/index-BYHTgAJJ.css 36.82 kB │ gzip: 10.03 kB +dist/assets/channel-BWaIGDQK.js 0.11 kB │ gzip: 0.12 kB +dist/assets/init-Gi6I4Gst.js 0.15 kB │ gzip: 0.13 kB +dist/assets/chunk-2Q5K7J3B-Bh00PV7B.js 0.19 kB │ gzip: 0.16 kB +dist/assets/chunk-JWPE2WC7-zllJnyes.js 0.22 kB │ gzip: 0.17 kB +dist/assets/chunk-XXDRQBXY-D7xLWH32.js 0.22 kB │ gzip: 0.20 kB +dist/assets/chunk-5VM5RSS4-DwRnlXdD.js 0.36 kB │ gzip: 0.27 kB +dist/assets/stateDiagram-v2-MP3YSRHH-CMni1PkL.js 0.39 kB │ gzip: 0.29 kB +dist/assets/classDiagram-ZZMXUADV-D-sblmQI.js 0.43 kB │ gzip: 0.30 kB +dist/assets/classDiagram-v2-VYDZK3BY-D-sblmQI.js 0.43 kB │ gzip: 0.30 kB +dist/assets/chunk-POPQ4Y6H-CV-hKTcv.js 0.50 kB │ gzip: 0.35 kB +dist/assets/swimlanesDiagram-VR7AAH4N-CSMO0mBI.js 0.51 kB │ gzip: 0.35 kB +dist/assets/infoDiagram-27XIBGKW-fa-zM8z6.js 0.55 kB │ gzip: 0.38 kB +dist/assets/sizeCapture-INFHLROL-DDBMTQik.js 0.90 kB │ gzip: 0.51 kB +dist/assets/ordinal-Cboi1Yqb.js 1.19 kB │ gzip: 0.57 kB +dist/assets/railroadDiagram-O6MQD6OU-BKJZqzzT.js 1.57 kB │ gzip: 0.73 kB +dist/assets/abnfDiagram-VCTEODGH-DMcEZewO.js 1.77 kB │ gzip: 0.87 kB +dist/assets/chunk-F27PBJKO-usLA3fwD.js 1.88 kB │ gzip: 0.83 kB +dist/assets/pegDiagram-XKGWAZYB-D5t7eX_t.js 1.92 kB │ gzip: 0.90 kB +dist/assets/ebnfDiagram-PWID7BFC-RqHNt9tT.js 1.97 kB │ gzip: 0.86 kB +dist/assets/arc-DzBaewGB.js 3.43 kB │ gzip: 1.48 kB +dist/assets/diagram-Z3DM3KII-DaRdRaxA.js 4.18 kB │ gzip: 1.81 kB +dist/assets/defaultLocale-DX6XiGOO.js 4.69 kB │ gzip: 2.18 kB +dist/assets/linear--qpe2Iaf.js 5.65 kB │ gzip: 2.30 kB +dist/assets/diagram-UQ7AKVKN-RZXoXHTp.js 6.04 kB │ gzip: 2.60 kB +dist/assets/pieDiagram-E7YTZNPT-Dn0Lbwkg.js 6.26 kB │ gzip: 2.60 kB +dist/assets/diagram-S7CK7UJ4-yML3LoUw.js 8.18 kB │ gzip: 3.65 kB +dist/assets/cynefinDiagram-5FMLGOSQ-CALcOVS7.js 10.06 kB │ gzip: 3.61 kB +dist/assets/stateDiagram-D77RDMKH-BSRSjCXA.js 10.33 kB │ gzip: 3.61 kB +dist/assets/dagre-GXQ25YYZ-Fc0ngcGC.js 10.64 kB │ gzip: 4.01 kB +dist/assets/diagram-VSXAHHWV-D-Jngocg.js 10.68 kB │ gzip: 4.11 kB +dist/assets/chunk-SVP7TREG-Xn5LeAbu.js 15.73 kB │ gzip: 4.41 kB +dist/assets/diagram-VX7I27RA-CqVb28LM.js 15.92 kB │ gzip: 5.68 kB +dist/assets/ishikawaDiagram-5VMMS53U-DYKYYyrU.js 17.43 kB │ gzip: 6.62 kB +dist/assets/kanban-definition-UXKFOSKX-D1DPeYzD.js 20.26 kB │ gzip: 7.18 kB +dist/assets/mindmap-definition-YA3MSWOX-BWGNSZTH.js 23.14 kB │ gzip: 7.80 kB +dist/assets/sankeyDiagram-P5KCCOFB-CqHgXeOu.js 23.16 kB │ gzip: 8.54 kB +dist/assets/journeyDiagram-3NMN7TZE-DereIpCw.js 23.54 kB │ gzip: 8.33 kB +dist/assets/wardleyDiagram-VM6X3IG4-CyLHCCcx.js 25.47 kB │ gzip: 6.77 kB +dist/assets/gitGraphDiagram-WWUBYQGX-CVvsIktm.js 29.38 kB │ gzip: 8.68 kB +dist/assets/erDiagram-RLTQ6QDP-B7deKO5n.js 30.76 kB │ gzip: 10.57 kB +dist/assets/timeline-definition-24CTP7MA-DO4WJS9A.js 30.79 kB │ gzip: 10.13 kB +dist/assets/requirementDiagram-BXWQKSXE-B_V5PvFV.js 30.97 kB │ gzip: 9.71 kB +dist/assets/quadrantDiagram-AXDQQJYC-Bz6zCFqR.js 33.98 kB │ gzip: 9.95 kB +dist/assets/layout-C2imKr5O.js 35.43 kB │ gzip: 12.74 kB +dist/assets/chunk-IMKFNOWR-C-Mq94A_.js 38.07 kB │ gzip: 12.51 kB +dist/assets/blockDiagram-I7D4REHJ-rec6-G1Z.js 41.72 kB │ gzip: 13.56 kB +dist/assets/vennDiagram-4TSXK5OY-rRqEaYZi.js 41.93 kB │ gzip: 15.67 kB +dist/assets/xychartDiagram-S5SC5T6Z-DWXv4LlT.js 44.47 kB │ gzip: 12.58 kB +dist/assets/chunk-TICWLB2K-yUyCyRlD.js 49.17 kB │ gzip: 15.74 kB +dist/assets/flowDiagram-HODETNUW-DwTSI7cN.js 61.84 kB │ gzip: 19.87 kB +dist/assets/c4Diagram-7LVT6UL2-kvq1lpAQ.js 65.44 kB │ gzip: 18.77 kB +dist/assets/ganttDiagram-EL5Y4UJY-BPXbiJG9.js 69.66 kB │ gzip: 23.54 kB +dist/assets/cose-bilkent-JH36ORCC-BIMvB8di.js 81.66 kB │ gzip: 22.46 kB +dist/assets/swimlanes-42K2YHIH-DgiZ0DVD.js 115.76 kB │ gzip: 42.49 kB +dist/assets/sequenceDiagram-WJ2MYXX4-CsY4WXBM.js 116.93 kB │ gzip: 31.11 kB +dist/assets/architectureDiagram-5GKGNRK7-CG8j7D4N.js 151.79 kB │ gzip: 43.12 kB +dist/assets/katex-HP8lGamR.js 261.31 kB │ gzip: 77.57 kB +dist/assets/cytoscape.esm-BB4DxJjf.js 443.04 kB │ gzip: 141.98 kB +dist/assets/cynefin-OW5HDTMX-Dit8THDr.js 687.91 kB │ gzip: 154.14 kB +dist/assets/index-DhyNmxJE.js 2,829.14 kB │ gzip: 805.59 kB + +(!) Some chunks are larger than 500 kB after minification. Consider: +- Using dynamic import() to code-split the application +- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks +- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit. +✓ built in 7.59s +precompress: 49 candidate(s) under /home/weichen/projects/Agent-System/web/dist + br assets/abnfDiagram-VCTEODGH-DMcEZewO.js 1774 → 798 (−55.0%) + gzip assets/abnfDiagram-VCTEODGH-DMcEZewO.js 1774 → 872 (−50.8%) + br assets/arc-DzBaewGB.js 3434 → 1285 (−62.6%) + gzip assets/arc-DzBaewGB.js 3434 → 1449 (−57.8%) + br assets/architectureDiagram-5GKGNRK7-CG8j7D4N.js 151788 → 36902 (−75.7%) + gzip assets/architectureDiagram-5GKGNRK7-CG8j7D4N.js 151788 → 42819 (−71.8%) + br assets/blockDiagram-I7D4REHJ-rec6-G1Z.js 41722 → 11938 (−71.4%) + gzip assets/blockDiagram-I7D4REHJ-rec6-G1Z.js 41722 → 13449 (−67.8%) + br assets/c4Diagram-7LVT6UL2-kvq1lpAQ.js 65438 → 15174 (−76.8%) + gzip assets/c4Diagram-7LVT6UL2-kvq1lpAQ.js 65438 → 18662 (−71.5%) + br assets/chunk-F27PBJKO-usLA3fwD.js 1876 → 730 (−61.1%) + gzip assets/chunk-F27PBJKO-usLA3fwD.js 1876 → 828 (−55.9%) + br assets/chunk-IMKFNOWR-C-Mq94A_.js 38069 → 10963 (−71.2%) + gzip assets/chunk-IMKFNOWR-C-Mq94A_.js 38069 → 12427 (−67.4%) + br assets/chunk-SVP7TREG-Xn5LeAbu.js 15728 → 3959 (−74.8%) + gzip assets/chunk-SVP7TREG-Xn5LeAbu.js 15728 → 4413 (−71.9%) + br assets/chunk-TICWLB2K-yUyCyRlD.js 49171 → 13023 (−73.5%) + gzip assets/chunk-TICWLB2K-yUyCyRlD.js 49171 → 15643 (−68.2%) + br assets/cose-bilkent-JH36ORCC-BIMvB8di.js 81662 → 19567 (−76.0%) + gzip assets/cose-bilkent-JH36ORCC-BIMvB8di.js 81662 → 22373 (−72.6%) + br assets/cynefin-OW5HDTMX-Dit8THDr.js 687914 → 124585 (−81.9%) + gzip assets/cynefin-OW5HDTMX-Dit8THDr.js 687914 → 153069 (−77.7%) + br assets/cynefinDiagram-5FMLGOSQ-CALcOVS7.js 10062 → 3233 (−67.9%) + gzip assets/cynefinDiagram-5FMLGOSQ-CALcOVS7.js 10062 → 3598 (−64.2%) + br assets/cytoscape.esm-BB4DxJjf.js 443036 → 119602 (−73.0%) + gzip assets/cytoscape.esm-BB4DxJjf.js 443036 → 141741 (−68.0%) + br assets/dagre-GXQ25YYZ-Fc0ngcGC.js 10641 → 3659 (−65.6%) + gzip assets/dagre-GXQ25YYZ-Fc0ngcGC.js 10641 → 4024 (−62.2%) + br assets/defaultLocale-DX6XiGOO.js 4688 → 2012 (−57.1%) + gzip assets/defaultLocale-DX6XiGOO.js 4688 → 2166 (−53.8%) + br assets/diagram-S7CK7UJ4-yML3LoUw.js 8182 → 3307 (−59.6%) + gzip assets/diagram-S7CK7UJ4-yML3LoUw.js 8182 → 3662 (−55.2%) + br assets/diagram-UQ7AKVKN-RZXoXHTp.js 6044 → 2343 (−61.2%) + gzip assets/diagram-UQ7AKVKN-RZXoXHTp.js 6044 → 2596 (−57.0%) + br assets/diagram-VSXAHHWV-D-Jngocg.js 10681 → 3764 (−64.8%) + gzip assets/diagram-VSXAHHWV-D-Jngocg.js 10681 → 4132 (−61.3%) + br assets/diagram-VX7I27RA-CqVb28LM.js 15922 → 5130 (−67.8%) + gzip assets/diagram-VX7I27RA-CqVb28LM.js 15922 → 5689 (−64.3%) + br assets/diagram-Z3DM3KII-DaRdRaxA.js 4176 → 1626 (−61.1%) + gzip assets/diagram-Z3DM3KII-DaRdRaxA.js 4176 → 1800 (−56.9%) + br assets/ebnfDiagram-PWID7BFC-RqHNt9tT.js 1970 → 784 (−60.2%) + gzip assets/ebnfDiagram-PWID7BFC-RqHNt9tT.js 1970 → 862 (−56.2%) + br assets/erDiagram-RLTQ6QDP-B7deKO5n.js 30755 → 9152 (−70.2%) + gzip assets/erDiagram-RLTQ6QDP-B7deKO5n.js 30755 → 10522 (−65.8%) + br assets/flowDiagram-HODETNUW-DwTSI7cN.js 61840 → 16342 (−73.6%) + gzip assets/flowDiagram-HODETNUW-DwTSI7cN.js 61840 → 19714 (−68.1%) + br assets/ganttDiagram-EL5Y4UJY-BPXbiJG9.js 69656 → 20654 (−70.3%) + gzip assets/ganttDiagram-EL5Y4UJY-BPXbiJG9.js 69656 → 23405 (−66.4%) + br assets/gitGraphDiagram-WWUBYQGX-CVvsIktm.js 29382 → 7766 (−73.6%) + gzip assets/gitGraphDiagram-WWUBYQGX-CVvsIktm.js 29382 → 8676 (−70.5%) + br assets/index-BYHTgAJJ.css 36824 → 8557 (−76.8%) + gzip assets/index-BYHTgAJJ.css 36824 → 9952 (−73.0%) + br assets/index-DhyNmxJE.js 2829141 → 625981 (−77.9%) + gzip assets/index-DhyNmxJE.js 2829141 → 798236 (−71.8%) + br assets/ishikawaDiagram-5VMMS53U-DYKYYyrU.js 17434 → 5995 (−65.6%) + gzip assets/ishikawaDiagram-5VMMS53U-DYKYYyrU.js 17434 → 6612 (−62.1%) + br assets/journeyDiagram-3NMN7TZE-DereIpCw.js 23536 → 7492 (−68.2%) + gzip assets/journeyDiagram-3NMN7TZE-DereIpCw.js 23536 → 8314 (−64.7%) + br assets/kanban-definition-UXKFOSKX-D1DPeYzD.js 20256 → 6416 (−68.3%) + gzip assets/kanban-definition-UXKFOSKX-D1DPeYzD.js 20256 → 7151 (−64.7%) + br assets/katex-HP8lGamR.js 261313 → 63694 (−75.6%) + gzip assets/katex-HP8lGamR.js 261313 → 77109 (−70.5%) + br assets/layout-C2imKr5O.js 35429 → 11552 (−67.4%) + gzip assets/layout-C2imKr5O.js 35429 → 12742 (−64.0%) + br assets/linear--qpe2Iaf.js 5654 → 2120 (−62.5%) + gzip assets/linear--qpe2Iaf.js 5654 → 2311 (−59.1%) + br assets/mindmap-definition-YA3MSWOX-BWGNSZTH.js 23140 → 6990 (−69.8%) + gzip assets/mindmap-definition-YA3MSWOX-BWGNSZTH.js 23140 → 7777 (−66.4%) + br assets/ordinal-Cboi1Yqb.js 1189 → 497 (−58.2%) + gzip assets/ordinal-Cboi1Yqb.js 1189 → 574 (−51.7%) + br assets/pegDiagram-XKGWAZYB-D5t7eX_t.js 1921 → 803 (−58.2%) + gzip assets/pegDiagram-XKGWAZYB-D5t7eX_t.js 1921 → 904 (−52.9%) + br assets/pieDiagram-E7YTZNPT-Dn0Lbwkg.js 6259 → 2379 (−62.0%) + gzip assets/pieDiagram-E7YTZNPT-Dn0Lbwkg.js 6259 → 2608 (−58.3%) + br assets/quadrantDiagram-AXDQQJYC-Bz6zCFqR.js 33975 → 8492 (−75.0%) + gzip assets/quadrantDiagram-AXDQQJYC-Bz6zCFqR.js 33975 → 9854 (−71.0%) + br assets/railroadDiagram-O6MQD6OU-BKJZqzzT.js 1571 → 663 (−57.8%) + gzip assets/railroadDiagram-O6MQD6OU-BKJZqzzT.js 1571 → 734 (−53.3%) + br assets/requirementDiagram-BXWQKSXE-B_V5PvFV.js 30965 → 8236 (−73.4%) + gzip assets/requirementDiagram-BXWQKSXE-B_V5PvFV.js 30965 → 9638 (−68.9%) + br assets/sankeyDiagram-P5KCCOFB-CqHgXeOu.js 23163 → 7806 (−66.3%) + gzip assets/sankeyDiagram-P5KCCOFB-CqHgXeOu.js 23163 → 8545 (−63.1%) + br assets/sequenceDiagram-WJ2MYXX4-CsY4WXBM.js 116927 → 25725 (−78.0%) + gzip assets/sequenceDiagram-WJ2MYXX4-CsY4WXBM.js 116927 → 30934 (−73.5%) + br assets/stateDiagram-D77RDMKH-BSRSjCXA.js 10326 → 3305 (−68.0%) + gzip assets/stateDiagram-D77RDMKH-BSRSjCXA.js 10326 → 3606 (−65.1%) + br assets/swimlanes-42K2YHIH-DgiZ0DVD.js 115756 → 36638 (−68.3%) + gzip assets/swimlanes-42K2YHIH-DgiZ0DVD.js 115756 → 42149 (−63.6%) + br assets/timeline-definition-24CTP7MA-DO4WJS9A.js 30794 → 9120 (−70.4%) + gzip assets/timeline-definition-24CTP7MA-DO4WJS9A.js 30794 → 10124 (−67.1%) + br assets/vennDiagram-4TSXK5OY-rRqEaYZi.js 41926 → 13970 (−66.7%) + gzip assets/vennDiagram-4TSXK5OY-rRqEaYZi.js 41926 → 15620 (−62.7%) + br assets/wardleyDiagram-VM6X3IG4-CyLHCCcx.js 25467 → 6076 (−76.1%) + gzip assets/wardleyDiagram-VM6X3IG4-CyLHCCcx.js 25467 → 6733 (−73.6%) + br assets/xychartDiagram-S5SC5T6Z-DWXv4LlT.js 44474 → 11050 (−75.2%) + gzip assets/xychartDiagram-S5SC5T6Z-DWXv4LlT.js 44474 → 12519 (−71.9%) + br fonts/OFL.txt 4474 → 1437 (−67.9%) + gzip fonts/OFL.txt 4474 → 1980 (−55.7%) +precompress: wrote 49 .br and 49 .gz +EXIT:0 diff --git a/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/npm-test.txt b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/npm-test.txt new file mode 100644 index 00000000..d2ad060d --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/npm-test.txt @@ -0,0 +1,517 @@ + +> shuimu-workbench@1.0.0 test +> tsx --test "src/**/!(*InteractiveCard*|*chatWindowArtifact*|*deliverableSync*).test.ts" && vitest run src/components/chat/InteractiveCard.test.ts src/components/chat/chatWindowArtifact.test.ts src/components/chat/deliverableSync.test.ts + +✔ AuthClient: 基础登录与 .env 超级管理员鉴权 (2.36032ms) +✔ AuthClient: 自主注册受控流程(仅填用户名密码姓名,职位强制绑定访客待指派) (0.506655ms) +✔ AuthClient: 开启注册需审批时,注册账号处于待审状态并禁止登录 (0.586823ms) +✔ AuthClient: 管理员创建账号必须在首次登录修改密码 (0.308111ms) +✔ mapUserAccount: snake_case Go contract → camelCase (1.500635ms) +✔ mapUserAccount: prefers snake_case when both present (0.292481ms) +✔ mapLoginResult + mapMeResult map nested user and top-level must_change_password (0.980681ms) +✔ mapUserAccountList maps admin users list (0.205094ms) +✔ AuthClient.login + fetchMe map real snake_case wire payloads (31.29732ms) +✔ shouldAutoRetryChat: disabled until idempotency (0.871114ms) +✔ shouldAutoRetryTask: transient only, max 2 (0.218044ms) +✔ shouldAutoRetryTask: document busy never retries (0.290802ms) +✔ shouldAutoRetry alias delegates to task path (0.214073ms) +✔ autoRetryDelayMs: 1s then 2s (0.168625ms) +✔ fallbackErrorClass: network → transient, workspace → resumable (0.126596ms) +✔ upsertChatSession surfaces safe INTERNAL envelope instead of raw JSON text (30.437616ms) +[sse] 忽略无法解析的帧: {not valid json SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2) + at JSON.parse () + at parseFrame (/home/weichen/projects/Agent-System/web/src/api/chatStream.ts:123:26) + at parseSSEChunk (/home/weichen/projects/Agent-System/web/src/api/chatStream.ts:147:20) + at TestContext. (/home/weichen/projects/Agent-System/web/src/api/chatStream.test.ts:41:31) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1402:25) + at Test.processPendingSubtests (node:internal/test_runner/test:974:18) + at Test.postRun (node:internal/test_runner/test:1542:19) + at Test.run (node:internal/test_runner/test:1467:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) +✔ parseSSEChunk: parses event and data lines (1.014139ms) +✔ parseSSEChunk: keeps incomplete frame in rest (0.246162ms) +✔ parseSSEChunk: flush parses final frame without trailing delimiter (0.308091ms) +✔ parseSSEChunk: ignores heartbeat comments and keeps last id (0.165105ms) +✔ parseSSEChunk: skips a single malformed frame and keeps parsing the next valid frame (2.574834ms) +▶ docgov lifecycle API client + ✔ claimDocument posts to /api/v1/docgov/claim with author role (31.264031ms) + ✔ submitForReview posts to /api/v1/docgov/submit-review (7.375151ms) + ✔ reviewDecision posts action and optional reason (0.895863ms) + ✔ approverDecision posts action and rollback_to (0.745978ms) + ✔ proposeModification posts to /api/v1/docgov/proposal (0.755858ms) + ✔ decideModificationProposal posts to /api/v1/docgov/proposal/decide (0.65576ms) +✔ docgov lifecycle API client (46.846989ms) +✔ updateFreeformDoc exposes CONCURRENCY_CONFLICT on 409 (41.44241ms) +✔ addProjectMember exposes structured ProjectSettingsApiError on 403 (25.500913ms) +✔ updateProjectMember surfaces safe INTERNAL message on 500 duty-sync failure (0.733898ms) +▶ projectSettingsError + ✔ keeps status, code, message and issues on PLAN_FROZEN (1.809536ms) + ✔ maps revision and concurrent conflicts to distinct recovery (0.198244ms) + ✔ joins validation issues but still keeps typed codes (0.186784ms) + ✔ prefers envelope code over legacy error field (0.160185ms) + ✔ unwraps nested business JSON inside VERSION_CONFLICT envelope (0.180435ms) + ✔ maps bare VERSION_CONFLICT envelope to retry_after_switch (0.122906ms) + ✔ retains the local draft when a late tab hits PLAN_FROZEN (0.107517ms) + ✔ prefers error_code over legacy bare error string (DOC_BUSY) (0.253902ms) +✔ projectSettingsError (4.914584ms) +✔ Project workflow types validation (1.167705ms) +✔ createTaskRun exposes DOC_BUSY detail without a retryable run (19.844201ms) +✔ parseSSEChunk tracks id seq for after_seq reconnect (0.378759ms) +✔ subscribeTaskRunEvents reconnects on EOF with advancing after_seq (1.803177ms) +✔ readTaskRunSSEOnce marks non-terminal EOF for reconnect (0.461516ms) +✔ subscribeTaskRunEvents fails fast on 401 without retrying (0.366009ms) +✔ subscribeTaskRunEvents fails fast on 404 without retrying (0.31701ms) +✔ buildTaskRequest: maps five project fields + task_key + user identity (1.154226ms) +✔ buildTaskRequest: omits optional fields when absent (0.176735ms) +✔ buildTaskRequest: includes materials when provided and non-empty (0.747018ms) +✔ buildResumeRequest: adds work_dir on top of task request (0.156125ms) +✔ buildTaskRequest: canonicalizes legacy doc-p doc_id (0.250932ms) +✔ buildTaskRequest: UI-05 pinned intake keeps task_key + doc_proj6109_intake (0.165745ms) +✔ buildResumeRequest: forwards add_steps when provided (0.121796ms) +✔ CategoryDock: 渲染顶部 🔔 未读提醒项及角标数字 (18.299496ms) +✔ CategoryDock: isAllReadCompleted 为 true 时渲染完成态提示 (3.04884ms) +✔ CategoryDock: 0 未读时不渲染红色未读角标 (2.771678ms) +✔ CategoryDock: 分类项包含依次跳转与遍历提示信息 (3.096658ms) +✔ InputArea keeps draft when send preflight fails (3.293343ms) +✔ InputArea clears draft only after accepted send (0.65808ms) +✔ InputArea shows thinking block message (0.181135ms) +✔ InputArea keeps draft when attachment registration fails (0.277352ms) +✔ isTemplateOnly 交付物应正确识别为初始模板展示态 (0.722139ms) +✔ 左侧栏鼠标严密绝对跟随与夹紧测试 (Left Rail Exact Pointer Following & Clamping) (0.799306ms) +✔ 右侧栏鼠标严密绝对跟随与反向位移测试 (Right Rail Exact Pointer Following & Inverted Delta) (3.654992ms) +✔ detects legacy mock and neuropulse/ten-projects ids (1.01765ms) +✔ detects colliding demo project ids (0.183125ms) +✔ applyRestoredFileRaw: 以 prev 为权威,只补丁缺失 fileRaw,不丢新增条目 (1.089738ms) +✔ applyRestoredFileRaw: 无可用 fileRaw 时返回同一引用 (0.158255ms) +✔ 每人独立私聊:同一项目不同用户拥有不同 sessionId (0.765487ms) +✔ 左侧会话列表按当前用户过滤,不可见他人私聊 (0.241232ms) +✔ 消息严格归属各自私聊 session,无跨用户混聊 (0.863025ms) +✔ 切换用户时 resolve 到该用户同项目私聊会话 (0.246963ms) +✔ resolve 优先有消息的私聊,跳过空壳 sess-p{N}-u{id} (0.3339ms) +✔ ensurePrivateSessionsForUser 为缺失用户补齐空私聊 (0.442747ms) +✔ 多项目会话消息隔离测试仍成立(endo vs ultrasound) (0.280472ms) +✔ window cache LRU: 驱逐带 userId 前缀的完整 cache key,不留孤儿条目 (1.133546ms) +✔ window cache: 读写同一 userId 前缀,跨用户不串读 (0.259122ms) +✔ parseAnchorHash: 正确解析标准深链 hash (1.784847ms) +✔ formatAnchorHash: 正确构造深链格式 (0.202834ms) +✔ navigateToAnchor: 快路径直接命中 DOM 节点 (0.499195ms) +✔ navigateToAnchor: 慢路径拉取分片后命中 (81.15696ms) +✔ resolveTurnAnchorId: 方案 A 轮次归一化测试 (0.867534ms) +✔ 全链路场景 1: 用户自主受控注册(仅填用户名/密码/姓名,职位严格绑定访客待指派) (1.631291ms) +✔ 全链路场景 2: 系统准入策略(关闭自主注册与开启强制审批流转) (0.68869ms) +✔ 全链路场景 3: 管理员开通账号首次强制改密闭环 (0.254653ms) +✔ 全链路场景 4: 环境标识随登录会话返回(dev/test/main) (1.144106ms) +✔ cleanHeadingContent: 手工多级序号前缀清洗与专业词汇保护 (1.220074ms) +✔ 标题多层级自动编号:单 H1 文档总标题自适应模式 (1 -> 1.1 -> 1.1.1) (0.459877ms) +✔ 标题多层级自动编号:多 H1 标准大章模式 (0.222793ms) +✔ 跨章节图、表、公式多级按章重编号测试 (表 1-1, 表 2-1, 图 3-1, 式 (4-1)) (0.225843ms) +✔ 有序列表与无序列表编号与渲染功能测试 (2.181595ms) +✔ 动态块操作闭环重编号验证 (Insert, Move, Delete) (0.284481ms) +✔ findMatchingBlockForReview: 能够精准按审查关键词(电气安全/EMC/竞品)定位段落块 (2.512585ms) +✔ findMatchingBlockForReview: 能够按外部指定 hint 线索精确命中目标块 (0.243222ms) +✔ createRejectConfirmationCard: 生成合规危险级驳回门禁卡片 (0.637661ms) +✔ isAffirmativeResponse: 准确识别双轨交互中的肯定意图输入 (0.243303ms) +✔ findLastActiveCard: 正确在会话历史中提取最新的待决策卡片 (0.258322ms) +✔ decrementActiveCardsTtl: 多轮对话超时递减与自动失效归档 (0.205684ms) +✔ applyConfirmRejectToDoc & applyUndoRejectToDoc: 驳回挂载批注与60s窗口期撤销完整闭环 (0.35438ms) +✔ resolveCardInMessages & cancelCardInMessages: 消息流中卡片终态沉淀 (0.194655ms) +✔ classifyRejectUtterance: 询问原因 → QUERY_REJECT_REASON (1.236494ms) +✔ classifyRejectUtterance: 明确执行 → EXECUTE_REJECT (0.206654ms) +✔ classifyRejectUtterance: 模糊 → OTHER (0.222504ms) +✔ resolveDocGovCard: 编制人查询 → 信息卡且无 confirm_reject (0.864404ms) +✔ resolveDocGovCard: 审核人执行 + reviewing → 确认卡 (0.386939ms) +✔ resolveDocGovCard: 编制人误说执行驳回 → 无权纯文本 (1.904913ms) +✔ resolveDocGovCard: 批准人 + approving → 终审确认卡 (0.242293ms) +✔ resolveDocGovCard: 编制人 OTHER → 引导查询无危险按钮 (0.139546ms) +✔ canConfirmReject: 仅审核/批准 (0.166155ms) +✔ deriveFallbackCategory identifies categories correctly (1.057539ms) +✔ enrichMessagesWithCategories backfills categories without overwriting existing ones (0.33838ms) +✔ Markdown ➔ Block AST 解析测试 (标题自适应编号与图表公式全链路) (7.07526ms) +✔ 动态重编号引擎测试 (多 H1 规范章节重排验证) (0.489675ms) +✔ Block AST ➔ Markdown 双向无损往返还原测试 (0.521475ms) +✔ 三层交付物多用户隐私绝对隔离测试 (Multi-user Strict Privacy Isolation) (1.873875ms) +✔ 一键共享至项目参考库流转测试 (Promote to Shared Reference Test) (0.283601ms) +✔ 撤回共享恢复私有草稿测试 (Revoke Shared Reference Test) (0.524474ms) +✔ 受控工作流纯净性红线检验 (Workflow Purity Security Red Line) (0.515395ms) +✔ isSameMessage: clientTurnId match (5.167817ms) +✔ isSameMessage: serverId match without turn (0.255322ms) +✔ filterPendingLiveMessages: real temp vs server id (0.244913ms) +✔ mergeLiveOverHistory preserves server id from live (0.236403ms) +✔ applyPhaseEvent: appends first phase as running node (2.731859ms) +✔ applyPhaseEvent: completes predecessors when a new phase arrives (0.212283ms) +✔ applyPhaseEvent: same phase refreshes label in place (no duplicate node) (0.223033ms) +✔ applyPhaseEvent: unknown phase falls back to hourglass meta (0.104037ms) +✔ applyPhaseEvent: prefers backend message over short label (0.084508ms) +✔ applyPhaseEvent: step_completed closes running entry without appending (0.111846ms) +✔ applyPhaseEvent: stage_completed closes entries (0.084058ms) +✔ applyPhaseEvent: task_failed appends warning node (0.095717ms) +✔ applyPhaseEvent: decorates label with step index / total steps (0.118007ms) +✔ applyPhaseEvent: repeated step_executing with step_index gets separate nodes (0.217453ms) +✔ applyPhaseEvent: ignores event without phase payload (0.106837ms) +✔ applyPhaseEvent: step_failed marks running entry as failed (keeps step label) (0.120837ms) +✔ applyPhaseEvent: step_failed appends failed node when no running entry exists (0.100117ms) +✔ applyPhaseEvent: retry of a failed step resets it to running (0.085328ms) +✔ PLAN_CONFIRM 卡片来自 PlanView 且不触达 AST (0.759087ms) +✔ 409 冲突把卡片标为 outdated 并换上最新 revision (0.180015ms) +✔ GET reconcile 用权威 PlanView 覆盖本地 executing,不假装成功 (0.127966ms) +✔ 过期计划映射 expired (0.107397ms) +✔ 非计划卡不被误判 (0.078247ms) +✔ 集成验证:项目切换时交付物严格隔离,新建项目即刻包含 7 份模板副本 (4.826057ms) +✔ 集成验证:三层隐私与项目物理隔离双重约束 (1.486596ms) +✔ 用例 1: DEFAULT_ROLES 包含 5 种默认角色且具备预期权限 (1.00728ms) +✔ 用例 2: 系统管理员 (ADMIN) 与具备 perm:manage 用户的通配豁免特权 (0.234323ms) +✔ 用例 3: 常规工作流基线可见性 (workflow:view_all) 判定 (0.245353ms) +✔ 用例 4: 单文档覆盖 - designated_only (仅编制/审核/批准人可见,其他用户被精确拦截) (0.235103ms) +✔ 用例 5: 单文档覆盖 - role_whitelist (仅白名单内角色可见,其他角色被精确拦截) (2.558334ms) +✔ 用例 6: 单文档覆盖 - public (放宽可见性,无 workflow:view_all 权限的访客也能查阅) (0.164935ms) +✔ 用例 7: 私密文档隔离强红线 (category === private 无论任何管理员或单文档覆盖,非创建者绝对不可见) (0.272462ms) +✔ 用例 8: 核心业务动作权限拦截判定 (canPerformDocAction & hasPermission) (0.182035ms) +✔ 用例 9: filterVisibleDeliverablesWithRBAC 能够批处理过滤大盘交付物 (3.571864ms) +✔ 集成场景 1: 动态新增合规审计员角色,并配置其仅能查阅特定单文档覆盖 (1.071709ms) +✔ 集成场景 2: 服务端 capabilities 变更后权限即刻生效 (0.241582ms) +✔ 集成场景 3: 单文档覆盖 designated_only 严格收紧与撤回恢复全流程 (0.232353ms) +✔ renderInlineMarkdown: renders bold, italic, strikethrough (1.370409ms) +✔ renderInlineMarkdown: escapes raw HTML tags to prevent XSS (0.517285ms) +✔ renderInlineMarkdown: blocks javascript: link hrefs (0.155095ms) +✔ renderInlineMarkdown: renders inline code and preserved line breaks (0.221124ms) +✔ renderInlineMarkdown: renders headings lists and tables for chat replies (0.380619ms) +✔ sanitizeMermaidCode: auto wraps unquoted labels containing brackets or comparison operators (0.33966ms) +(node:367351) ExperimentalWarning: localStorage is not available because --localstorage-file was not provided. +(Use `node --trace-warnings ...` to show where the warning was created) +✔ DOCX 渲染器行内文本与公式解析测试 (parseInlineContent) (5.175546ms) +✔ DOCX 渲染器 LaTeX 原生 OMML 转换引擎测试 (latexToOmml) (2.148146ms) +✔ DOCX 渲染器 Mermaid 语法净化与容错测试 (sanitizeMermaidCode) (0.282702ms) +✔ DOCX 渲染器 SVG 净化防 Canvas 污染测试 (cleanSvgForCanvas) (0.380619ms) +✔ DOCX 渲染器全量编译测试 (buildDocxModel & exportDocumentToDocx) (53.321226ms) +✔ XLSX 渲染器多表提取与工作簿编译测试 (buildXlsxWorkbook & exportDocumentToXlsx) (6.375551ms) +▶ Reply Document IR & Export Engine + ✔ deriveReplyTitle and sanitizeFilename derive title and safe file name (1.434858ms) + ✔ deriveReplyTitle handles text without heading (0.302901ms) + ✔ parseReplyMarkdown parses headers, lists, code, and tables (2.02298ms) + ✔ renderClipboardHtml renders self-contained styling for office tools (0.579783ms) + ✔ renderClipboardHtml converts LaTeX formulas to native MathML for Word/Office (4.175486ms) + ✔ renderPlainText strips formatting cleanly (0.517545ms) + ✔ serializeMarkdown round-trips to valid markdown (0.387529ms) + ✔ stripUnsafeHtml strips script tags and dangerous event handlers (0.642771ms) +✔ Reply Document IR & Export Engine (11.944965ms) +✔ createProjectTemplateDeliverables: 必须精确生成 7 份全生命周期受控文档副本 (4.38929ms) +✔ resolveRequirementsTaskKey prefers intake for 初始需求表 (1.392979ms) +✔ resolveRequirementsTaskKey selects user_requirements for explicit URS (0.190325ms) +✔ UI-02 first-round prompt with 首轮+用户需求+摸底 must be intake not user_requirements (2.768718ms) +✔ 首轮 / 需求摸底 / 摸底 beat bare 用户需求 (0.185615ms) +✔ bare 用户需求 without V0.1 markers stays user_requirements (0.138556ms) +✔ matchTemplateDeliverable uses intake key when pool only has URS template (0.530825ms) +✔ resolveTaskTemplateForMessage keeps intake key with URS-only pool (0.229823ms) +✔ matchTemplateDeliverable prefers intake when both templates exist (0.859164ms) +✔ read-only request does not start a document task: 总结一下用户初始需求表 (0.235643ms) +✔ read-only request does not start a document task: 查看用户初始需求表 (0.159335ms) +✔ read-only request does not start a document task: 用户初始需求表现在谁在编辑 (1.497015ms) +✔ read-only request does not start a document task: 分析一下用户需求表里的风险 (0.090067ms) +✔ read-only request does not start a document task: 用户初始需求表 (0.608802ms) +✔ explicit write action still starts intake (0.129526ms) +✔ explicit update action still starts URS (0.092547ms) +✔ UI-05 prompt without active doc still keyword-routes to feasibility_reg (2.181215ms) +✔ UI-05 prompt + registered intake pins intake and doc_proj6109_intake (0.32544ms) +✔ read-only current-doc request does not start a task (0.121597ms) +✔ template-only active slot is not pinned; keywords still apply (0.101057ms) +✔ explicit named deliverable can switch away from active intake (0.168065ms) +✔ domain 注册法规 without current-doc deixis still routes feasibility_reg (0.073708ms) +✔ mismatched title vs docId suffix prefers docId taskKey (0.081858ms) +✔ deixis 当前文档 pins registered intake (0.086398ms) +✔ deixis 本文件 pins registered intake (0.049318ms) +✔ deixis 这份文档 pins registered intake (0.043139ms) +✔ deixis 本文档 pins registered intake (0.040189ms) +✔ deixis 本受控文档 pins registered intake (0.042068ms) +✔ no active doc does not invent a pin (0.081707ms) +✔ UI-11 named 系统设计输入 beats negated 需求 background (0.070618ms) +✔ mixed generate-design + do-not-cover-requirements stays prelim_design (0.055039ms) +✔ current-doc pin still wins when no other deliverable is named (0.065488ms) +▶ document revision overlay modes + ✔ pure hides all decorations (0.844865ms) + ✔ manual only marks human hunks (0.249672ms) + ✔ all marks human and agent differently (0.155085ms) + ✔ same human hunk stays amber in manual and all (no pairwise style guarantee) (2.599062ms) + ✔ exposes needs-content error code for empty orphan docs (0.164005ms) + ✔ matches overlay hunks by section or text when ids differ (0.297302ms) + ✔ aligns client block ids from head_block_map by order (0.232113ms) +✔ document revision overlay modes (10.336083ms) +▶ toolbar refresh + report entry reachability + ✔ pickLatestChangeReportId prefers highest version with report (0.318001ms) + ✔ first squad run alone still exposes a reachable report id (0.185435ms) + ✔ second squad run keeps both history report entries and latest shortcut (4.05202ms) + ✔ revisionRefreshKey is an independent refresh signal from docId (0.214494ms) +✔ toolbar refresh + report entry reachability (5.153077ms) +▶ change report view model + ✔ surfaces risk items, project impact, doc impacts with advice, and open questions (2.721909ms) + ✔ marks complete analysis as PI narrative vs factual fields (0.768257ms) + ✔ preferReportId prioritizes pending over committed latest (0.119146ms) +✔ change report view model (3.838086ms) +▶ revisionToolbarMount + ✔ exposes clear non-error template preview hint copy (0.860824ms) + ✔ template doc-p + isTemplateOnly=true does not mount (0.230023ms) + ✔ legacy doc-001 does not mount (0.235583ms) + ✔ unregistered doc-upload orphan does not mount (must register first) (0.270042ms) + ✔ server-registered upload (doc-free) and freeform mount (0.33066ms) + ✔ canonical doc_proj and generated non-template doc-p mount (0.199074ms) +✔ revisionToolbarMount (4.420588ms) +✔ useChatWindow: 所有 saveSessionWindowCache 调用必须传入 userId (1.060028ms) +✔ window fetch race: stale response ignored by generation (1.059468ms) +✔ mapRecordToMessage: 正确映射服务端消息记录为前端 ChatMessage (1.560494ms) +✔ refresh window: 空 messages 日桶不算有效窗口 (0.263342ms) +✔ mapRawBucket: 正确映射服务端按日分片桶 (0.288572ms) +✔ Category navigation: chip 选中后准确匹配目标分类最新轮次并触发归一化定位 (0.257992ms) +✔ turn runtime: session A stream survives session B switch (1.183235ms) +✔ abortAllSessions aborts controllers across all sessions (0.33573ms) +✔ syncAdminUserDirectories calls local reload then parent onRefreshUsers (1.384819ms) +✔ syncAdminUserDirectories fails closed when onRefreshUsers is a dead/missing prop (0.601742ms) +✔ syncAdminUserDirectories surfaces parent refresh errors (no silent swallow) (0.259902ms) +✔ afterProjectMemberPersisted syncs members then refreshes workflow docs (1.498676ms) +✔ afterProjectMemberPersisted does not refresh when member sync fails (0.549044ms) +▶ blockEditorDialogPolicy + ✔ pins an accessible dialog label and exact save name (0.949511ms) + ✔ rejects page-wide fuzzy /保存/ that would hit private-library buttons (0.383579ms) +✔ blockEditorDialogPolicy (2.562113ms) +▶ resolveSaveTargetBlock + ✔ keeps the editor and does not fake success when the block id is gone (0.640411ms) + ✔ never retargets another block that happens to share the same content (0.752827ms) +✔ resolveSaveTargetBlock (2.325531ms) +▶ editor session document binding + ✔ fail-closes when activeDocId drifts away from the opened editor doc (0.229053ms) + ✔ accepts a save only when the editor is still bound to the active doc (0.151646ms) + ✔ blocks switching documents while the editor is open (0.164595ms) +✔ editor session document binding (0.895543ms) +▶ interpretBlockSaveError + ✔ 409 keeps the draft in the editor (0.563334ms) + ✔ other PATCH failures also keep the editor (2.113537ms) +✔ interpretBlockSaveError (3.085348ms) +▶ blockedGovernanceCopy + ✔ recognizes blocked run status and fixed hint, not generation success (0.807416ms) +✔ blockedGovernanceCopy (1.814866ms) +▶ commentPolicy + ✔ lets author/reviewer/approver annotate and keeps their comment role (0.833775ms) + ✔ keeps viewer read-only: no add entry and no reviewer/approver cast (0.152925ms) +✔ commentPolicy (2.108627ms) +▶ controlledContentInvalidation + ✔ hydrate success then late list_sync keeps cache/ready and does not bump resume (1.875694ms) + ✔ post_task invalidation drops active cache and requires a new hydrate epoch (0.317201ms) + ✔ editor open / save epoch keeps draft and rejects a stale GET after PATCH (0.552254ms) + ✔ never drops the active doc without signaling rehydrate (0.468966ms) + ✔ candidate helper keeps template / doc-001 / freeform / upload out of wipe lists (0.505365ms) + ✔ dropRecordKeys is a no-op when ids are absent (0.141286ms) +✔ controlledContentInvalidation (6.685832ms) +▶ controlledDocHydrate + ✔ shouldLoadControlledContent when non-template workflow doc (4.623543ms) + ✔ shouldLoadControlledContent rejects local legacy/mock ids like doc-001 (4.355051ms) + ✔ shouldLoadControlledContent keeps canonical, freeform-off, and template preview rules (0.33535ms) + ✔ hydrateControlledDocumentContent never requests content/revision for doc-001 (0.371039ms) + ✔ hydrateControlledDocumentContent never requests for template preview or registered freeform (0.530244ms) + ✔ shouldLoadControlledContent stays inside toolbar server-backed eligibility (0.3313ms) + ✔ resolveCanonicalDocId maps doc-p56-intake to doc_proj56_intake (0.515705ms) + ✔ canonicalDocIdForTask maps doc-p56-research to doc_proj56_research (0.32603ms) + ✔ hydrate treats projection missing as ledger content, not 404 (4.001802ms) + ✔ attaches hydrate ETag as PATCH baseline and ignores a later GET (1.843485ms) + ✔ rejects hydrate when revision-state network or 5xx fails (1.925553ms) + ✔ aligns markdownToBlockAST random ids onto revision-state head_block_map (3.095228ms) + ✔ leaves Math.random ids when head_block_map is omitted (why gate + fail-closed remain) (3.505966ms) + ✔ rejects controlled hydrate on NOT_FOUND or NEEDS_CONTENT (0.479186ms) + ✔ fingerprint stays stable when only object identity changes (0.182494ms) + ✔ dedupes in-flight hydrate for the same doc fingerprint (0.315051ms) + ✔ aligned late hydrate still cannot overwrite a PATCH-success baseline (0.519714ms) +✔ controlledDocHydrate (31.258232ms) +✔ createClientTurnId uses randomUUID when available (0.768398ms) +✔ createClientTurnId falls back to getRandomValues UUID v4 (0.452466ms) +✔ createClientTurnId throws when no secure random API exists (0.405498ms) +✔ selectNonCreatorInitialMembers skips creator only (1.519185ms) +✔ buildManagedMemberUpsertPayload sends document_duty on add/update/restore (0.294151ms) +✔ buildInitialMemberUpsertPayload maps reviewer UI role to member API role (0.285552ms) +✔ persistInitialProjectMembers awaits each add and stops on failure (2.529205ms) +✔ ensureCreatedProjectWithMembers skips recreate on bootstrap id and retries members (0.793066ms) +✔ beginCreateProjectSubmit rejects concurrent second entry until end (30.99431ms) +✔ resolveCreateProjectSubmitPhase uses bootstrap to choose syncing label (0.368889ms) +✔ createProjectSubmitButtonLabel and close block while submitting (0.197224ms) +✔ shouldCloneMedicalTemplates skips the 7-pack for software domain (0.125417ms) +▶ resolveCurrentDocRole + ✔ resolves A as author by authoritative user id (1.071268ms) + ✔ does not promote reviewer B or stranger to author (0.209004ms) + ✔ resolves real USER_B id as reviewer and never name-promotes placeholder 2 (0.162735ms) + ✔ fail-closes unknown identity to viewer even if projectPermission says author (0.151426ms) + ✔ matches name only for legacy local docs without duty ids (0.116057ms) + ✔ does not name-promote when server controlled duties are zero ids (0.128666ms) +✔ resolveCurrentDocRole (3.236863ms) +▶ currentProjectRestore + ✔ rejects empty project names (1.107647ms) + ✔ rejects page-wide project name when banner and sidebar both match (0.283262ms) + ✔ scopes the visible entry to complementary exact title (0.898173ms) + ✔ scopes the current project assertion to banner exact text (0.420938ms) +✔ currentProjectRestore (3.969582ms) +▶ deliverableLeaseSync + ✔ returns the same array reference when leases are unchanged (0.907253ms) + ✔ returns a new array only when a lease actually changes (0.212504ms) +✔ deliverableLeaseSync (2.162356ms) +▶ docGovActionVisibility + ✔ hides all approval actions on unmanaged docs (0.794286ms) + ✔ gates separated duties by user id, not folded role label (0.191454ms) + ✔ author_only: same user holds all three duties and sees all actions (0.239592ms) + ✔ author_only: non-duty user sees no actions (0.127156ms) +✔ docGovActionVisibility (2.670331ms) +▶ documentBlockDomAttrs + ✔ marks a real DocumentBlock in pure mode without overlay kind (0.866665ms) + ✔ keeps the same stable attrs when overlay is later attached separately (0.221383ms) +✔ documentBlockDomAttrs (2.152296ms) +▶ durableBodySnippet + ✔ rejects empty persisted snippets (0.825675ms) + ✔ lets a later successful revision replace the old snippet (0.155776ms) + ✔ does not promote a conflict-failed draft to the expected body (0.080487ms) +✔ durableBodySnippet (1.778697ms) +▶ dutyCellAssertion + ✔ parses author, reviewer, and viewer from select value or badge role (2.68939ms) + ✔ must not infer author from a row whose option labels include 编制负责人 (2.749208ms) + ✔ accepts only a disabled non-assignable reviewer select (0.192935ms) + ✔ rejects an enabled reviewer select when the reviewer cannot assign leads (0.138596ms) + ✔ rejects canAssignLead=true even if the reviewer select is disabled (0.104027ms) + ✔ rejects viewer, author, approver, and unknown values for USER_B (0.147196ms) +✔ dutyCellAssertion (7.283944ms) +▶ decideHydrationApply + ✔ applies only when gen, doc, save epoch match and editor is closed (1.169155ms) + ✔ rejects a response for another document (0.202464ms) + ✔ rejects a superseded generation (0.144535ms) +✔ decideHydrationApply (2.470667ms) +▶ createEditorHydrationGate + ✔ does not start a new hydrate while the editor is open (UI-06 fill-window GET) (0.3481ms) + ✔ deferred hydrate that resolves after the editor opens does not replace the session (0.179175ms) + ✔ covers hydrate resolving between fill and save (2.726659ms) + ✔ asks for a hydrate resume after editor_open blocked the first apply (0.298881ms) + ✔ asks for a hydrate resume when beginHydration is skipped while editing (0.167885ms) + ✔ requestHydrationResume is consumed once after a protected edit session (0.145506ms) + ✔ late hydrate after a successful PATCH does not roll back the new baseline (0.199994ms) +✔ createEditorHydrationGate (4.561375ms) +✔ mergeSessionUser prefers session identity over stale catalog entry (1.34047ms) +▶ playwrightPhaseArtifacts + ✔ defaults unknown or empty UI_PHASE to main (0.708309ms) + ✔ keeps post-restart distinct from main so the second run cannot overwrite traces (0.219003ms) + ✔ keeps main results at the historical filename and isolates post-restart recordPass (0.234473ms) + ✔ shares phase-state and secrets across phases (0.114226ms) +✔ playwrightPhaseArtifacts (2.329371ms) +✔ resolvePostTaskActiveDocId prefers server doc_proj for taskKey (0.830936ms) +✔ resolvePostTaskActiveDocId honors preferred server doc id (0.183355ms) +✔ shouldSwitchTemplateSlotToServer when active is local template slot (0.126516ms) +✔ pollUntilPostTaskServerDoc retries until same taskKey server doc appears (0.317901ms) +✔ pollUntilPostTaskServerDoc returns null after maxAttempts without relying on setState (0.195934ms) +✔ mapServerProjectToProjectInfo maps creator and preserves local members (0.949132ms) +✔ mergeProjectLists drops local-only demo seed projects by default (0.375909ms) +✔ mergeProjectLists can retain local-only demo when explicitly opted in (0.191375ms) +✔ documentDutyFromMemberRole maps UI reviewer and creator (0.725779ms) +✔ exclusiveMemberRoleChange keeps a single reviewer (0.211814ms) +✔ memberRoleFromDocumentDuty does not invent reviewer from access role (0.142846ms) +✔ toApiProjectRole maps doc roles to member and creator to owner (0.839005ms) +✔ fromApiProjectRole maps owner to creator for project creator (0.247083ms) +✔ mapApiMemberToProjectMember builds UI record (0.200744ms) +▶ projectSettingsDuties + ✔ toggles full to none by atomically clearing duties (1.334491ms) + ✔ blocks partial saves and locates required/governance issue codes (0.474566ms) + ✔ allows an all-none configurable draft without three members (0.207014ms) + ✔ requires three members once any document is managed (0.099128ms) + ✔ resolves author_only to A/A/A without three-member requirement (1.454897ms) + ✔ rejects author_only without author or with conflicting reviewers (0.202334ms) + ✔ mixed full + author_only still requires three members for the full doc (0.136956ms) + ✔ toggleAuthorOnly clears review/approve slots and restores on disable (0.171035ms) +✔ projectSettingsDuties (5.255444ms) +▶ projectSettingsMode + ✔ enters frozen when can_edit_plan is false or the project is active (0.787167ms) + ✔ stays planning only for an editable draft (0.133746ms) + ✔ lists enabled duties for the frozen snapshot and skips disabled tasks (0.733108ms) + ✔ keeps the local draft after PLAN_FROZEN refresh and switches to frozen (0.189694ms) +✔ projectSettingsMode (2.730289ms) +▶ revisionEtag + ✔ uses stale hydrate ETag for PATCH and never the latest GET (0.764917ms) + ✔ does not invent 0:0 when controlled hydrate misses etag or errors (0.169775ms) + ✔ refuses controlled save without a hydrate ETag (0.458676ms) + ✔ allows freeform bootstrap 0:0 only (0.129586ms) +✔ revisionEtag (2.610083ms) +▶ saveControlledBlockEdit + ✔ successful save calls PATCH once with bound docId, hydrate ETag, body and client_block_map (2.557825ms) + ✔ does not PATCH when editorDocId and activeDocId diverge (0.179475ms) + ✔ does not PATCH on stale block id and keeps the draft (0.191045ms) + ✔ 409 does not close the editor or drop the draft (0.651871ms) + ✔ network errors do not close the editor or drop the draft (0.230693ms) +✔ saveControlledBlockEdit (5.02899ms) +✔ parseProjectIdFromSessionId decodes sess-p{id}-u{user} (0.731609ms) +✔ resolveSessionProjectId prefers explicit id then session id encoding (0.132386ms) +✔ mapServerSessionToProjectSession fills project metadata (0.582313ms) +✔ mergeSessionLists keeps local stage while applying server counts (0.213144ms) +▶ squadCompletionObservation + ✔ accepts a directly completed adjacent assistant without requiring inflight (0.984411ms) + ✔ accepts a turn that passed through inflight then completed (0.189744ms) + ✔ rejects error terminals, missing correspondence, and visible errors (0.173405ms) + ✔ keeps waiting while still inflight and does not treat inflight as success (0.122086ms) +✔ squadCompletionObservation (2.565484ms) +✔ resolveReconnectRunId prefers explicit opts and ignores attempt without opts (0.712449ms) +✔ shouldSubscribeExistingRun only for in-flight statuses (0.135776ms) +✔ isRemoteTaskTerminal covers confirmation and quality_failed (0.147646ms) +✔ nextAttemptOpts carries retained runId for reconnect without new POST (0.145395ms) +▶ uploadFreeformRegister + ✔ buildUploadClientKey prefers explicit hint then doc-upload stamp (0.714309ms) + ✔ registerUploadAttachments hard-fails without creating orphan on API error (12.912846ms) + ✔ registerUploadAttachments rejects local doc-upload id from server (0.35008ms) + ✔ registerUploadAttachments returns server doc-free ids and freeform facts (0.477516ms) + ✔ deliverableFromFreeformUpload maps server id and upload facts (0.204844ms) +✔ uploadFreeformRegister (15.81998ms) +▶ workflowCategoryNavigation + ✔ private menu cannot see doc_proj* so it must switch to workflow before exact select (1.225724ms) + ✔ fail-closes when the workflow menu is showing but the exact doc is missing (0.290642ms) + ✔ label and taskKey opens always require the workflow category (0.143806ms) +✔ workflowCategoryNavigation (2.876514ms) +▶ no external Google Fonts on login shell + ✔ index.html has no fonts.googleapis / fonts.gstatic (0.725838ms) + ✔ styles reference self-hosted fonts.css (0.230633ms) +✔ no external Google Fonts on login shell (1.651031ms) +▶ 用户上传文档多格式在线预览体系与渲染分流测试 + ✔ 1. 上传附件时正确注入 sourceType: "uploaded_file" 与完整 fileMeta 元数据 (1.024029ms) + ✔ 2. 扩展名分类规则准确分发至对应的高保真渲染器类型 (0.32923ms) + ✔ 3. 受控工作流交付物严格隔离:绝不命中 FilePreviewPanel,保持 BlockRenderer 纯净 (0.223224ms) + ✔ 4. 上传文档支持受控流转:从个人私密一键共享至项目参考库后,在线预览依然可用且权限标态正确 (0.195064ms) +✔ 用户上传文档多格式在线预览体系与渲染分流测试 (2.971082ms) +▶ 项目立项、成员职责分配与退出留痕治理测试套件 + ✔ 1. 默认项目库包含完整元数据与法定三权成员 (active 状态) (0.733548ms) + ✔ 2. 跨用户会话可见性严格隔离:未加入成员完全不可见 (0.314491ms) + ✔ 3. 创建者添加新成员并分配职责后,新成员即刻可见该项目会话 (0.246873ms) + ✔ 4. 中途加入后期退出 (exited):保留项目会话但标记已退出只读留痕 (0.207754ms) + ✔ 5. 超级管理员 (ADMIN) 享有全量通配可见与管理兜底特权 (0.154515ms) +✔ 项目立项、成员职责分配与退出留痕治理测试套件 (3.121828ms) +▶ 新建项目工程文档模板质量与纯净性保障套件(对标官方参考系统) + ✔ 1. 必须精确生成 7 份官方全流程受控工作流模板且标记纯净 (0.663741ms) + ✔ 2. 杜绝旧项目 CUSA-NeuroPulse 串号泄露,动态注入新项目信息 (0.617142ms) + ✔ 3. 严格对标参考系统模板规范要素,绝非 3 行残缺摘要 (0.458326ms) + ✔ 4. 缓存丢失场景下即时自愈重建 AST 能力验证 (0.245863ms) +✔ 新建项目工程文档模板质量与纯净性保障套件(对标官方参考系统) (5.664852ms) +▶ 用户上传文档私密归档与共享流转测试 + ✔ 10 个评审项目均包含已共享的 URS 交付物与 AST,支持跨角色合规查阅且会话均以 assistant 闭环 (2.264823ms) + ✔ 用户上传附件自动生成私密交付物并在 AST 中建立隔离,支持流转至项目参考库 (1.906043ms) + ✔ 单轮对话支持一次上传多个文档(上限5个),批量生成私密交付物并遵循数量约束 (0.720578ms) +✔ 用户上传文档私密归档与共享流转测试 (5.968453ms) +▶ 工作流切换与消息展示时序测试套件 + ✔ 1. 当存在历史 seed 消息且追加新实时消息时,新消息必须严格沉淀在末尾 (1.040629ms) + ✔ 2. 切回项目时相同正文不应因 temp/server id 差异重复展示 (0.243113ms) +✔ 工作流切换与消息展示时序测试套件 (2.272152ms) +✔ DeliverableItem 必须包含 projectId 与 isTemplateOnly 属性 (0.750318ms) +ℹ tests 406 +ℹ suites 36 +ℹ pass 406 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 1236.897492 + + RUN v5.0.0 /home/weichen/projects/Agent-System/web + +(node:370484) ExperimentalWarning: localStorage is not available because --localstorage-file was not provided. +(Use `node --trace-warnings ...` to show where the warning was created) + + Test Files 3 passed (3) + Tests 32 passed (32) + Start at 03:44:36 + Duration 539ms (import 52%, transform 42%, tests 5%, worker 1%) + +EXIT:0 diff --git a/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/ownership-pg.txt b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/ownership-pg.txt new file mode 100644 index 00000000..18108484 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-18-wave4-hardening-r1-r8/ownership-pg.txt @@ -0,0 +1,31 @@ +=== RUN TestChatSessionOwnership_CrossUserHijackDenied_PG +2026/09/18 03:45:16 goose: no migrations to run. current version: 52 +2026/09/18 03:45:16 goose: no migrations to run. current version: 52 +2026/09/18 03:45:16 [kb] offline=true reason=empty_kb_db_password detail=config: KB_DB_PASSWORD is empty; provide it in root .env (layered with configs/.env.{ENV}) or process environment +{"time":"2026-09-18T03:45:16.71718205Z","level":"INFO","msg":"intent_recognition","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2} +{"time":"2026-09-18T03:45:16.718242398Z","level":"INFO","msg":"coordinator_route","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2,"channel":"DIALOGUE","reason":"对话类意图,由项目助手即时作答"} +{"time":"2026-09-18T03:45:16.729818684Z","level":"WARN","msg":"ctxmgr_reflect_msgs_scan_incomplete","error":"can't scan into dest[2] (col: user_id): cannot scan NULL into *int64"} +{"time":"2026-09-18T03:45:16.730607491Z","level":"INFO","msg":"intent_recognition","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2} +{"time":"2026-09-18T03:45:16.730669279Z","level":"INFO","msg":"coordinator_route","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2,"channel":"DIALOGUE","reason":"对话类意图,由项目助手即时作答"} +--- PASS: TestChatSessionOwnership_CrossUserHijackDenied_PG (0.17s) +=== RUN TestChatSessionOwnership_CrossUserHijackDenied_Memory +{"time":"2026-09-18T03:45:16.732054648Z","level":"INFO","msg":"[kb] offline=true reason=empty_kb_db_password detail=config: KB_DB_PASSWORD is empty; provide it in root .env (layered with configs/.env.{ENV}) or process environment"} +{"time":"2026-09-18T03:45:17.237092662Z","level":"INFO","msg":"intent_recognition","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2} +{"time":"2026-09-18T03:45:17.237182809Z","level":"INFO","msg":"coordinator_route","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2,"channel":"DIALOGUE","reason":"对话类意图,由项目助手即时作答"} +{"time":"2026-09-18T03:45:17.238873349Z","level":"INFO","msg":"intent_recognition","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2} +{"time":"2026-09-18T03:45:17.239007575Z","level":"INFO","msg":"coordinator_route","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2,"channel":"DIALOGUE","reason":"对话类意图,由项目助手即时作答"} +--- PASS: TestChatSessionOwnership_CrossUserHijackDenied_Memory (0.51s) +PASS +ok chat-agent-system/internal/server 0.687s +=== RUN TestChatSessionOwnership_CrossUserHijackDenied_PG +2026/09/18 03:45:55 goose: no migrations to run. current version: 52 +2026/09/18 03:45:55 goose: no migrations to run. current version: 52 +2026/09/18 03:45:55 [kb] offline=true reason=empty_kb_db_password detail=config: KB_DB_PASSWORD is empty; provide it in root .env (layered with configs/.env.{ENV}) or process environment +{"time":"2026-09-18T03:45:56.16685721Z","level":"INFO","msg":"intent_recognition","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2} +{"time":"2026-09-18T03:45:56.167778443Z","level":"INFO","msg":"coordinator_route","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2,"channel":"DIALOGUE","reason":"对话类意图,由项目助手即时作答"} +{"time":"2026-09-18T03:45:56.178783276Z","level":"WARN","msg":"ctxmgr_reflect_msgs_scan_incomplete","error":"can't scan into dest[2] (col: user_id): cannot scan NULL into *int64"} +{"time":"2026-09-18T03:45:56.179404118Z","level":"INFO","msg":"intent_recognition","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2} +{"time":"2026-09-18T03:45:56.179448296Z","level":"INFO","msg":"coordinator_route","intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","confidence":0.2,"channel":"DIALOGUE","reason":"对话类意图,由项目助手即时作答"} +--- PASS: TestChatSessionOwnership_CrossUserHijackDenied_PG (0.22s) +PASS +ok chat-agent-system/internal/server 0.224s