diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 7953bcc7..5d3ec609 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -1,7 +1,7 @@ # 当前焦点 **更新**: 2026-09-19 -**production_ready**: yes(门禁 B1–B4 已签;dev schema 52;test `schema_version=48`;main `schema_version=52`、`env=main`。不把 fallback change report 写成完整 PI) +**production_ready**: yes(门禁 B1–B4 已签;dev/test/main schema 52。不把 fallback change report 写成完整 PI) ## 焦点 diff --git a/.doc/ops/long-term/2026-09-19-audit-remainder.md b/.doc/ops/long-term/2026-09-19-audit-remainder.md index 8d54de6e..affdf742 100644 --- a/.doc/ops/long-term/2026-09-19-audit-remainder.md +++ b/.doc/ops/long-term/2026-09-19-audit-remainder.md @@ -41,9 +41,20 @@ updated: 2026-09-19 | L1 | done。健康检查 2 秒缓存 + 2 秒 DB 超时 | | L2 | done。SSE marshal 失败写 `event: error` 帧 | | L3 | done。`ReplayPlan.Digest` 含 `DryRun`;dry-run 摘要不能授权 apply | +| M15 | done。生产 `claimSQL` 与 `MemoryBus` 共用 `VersionClaimable`:版本 0 可领,版本化事件不能跳过 checkpoint。checkpoint 只前进。`TestVersionClaimableMatchesMemoryBusGap`、`TestGapDoesNotSkipVersion` | +| H3 负例 | done。`TestListChatSessions_CrossUserIs403` | +| FE 缓存 | done(版本字段)。窗口缓存写入 `{schema,data}`,无 schema 的旧载荷丢弃。`window cache: 无 schema` | +| FE lucide | 关闭理由:`package-lock.json` 锁定 `lucide-react@1.42.0`,来源 `registry.npmjs.org`,带 integrity。`^1.16.0` 是合法的 1.x 范围,不是来源不明的包 | +| FE App.tsx | 仍开放。4600 行拆 hook 是独立重构,本轮未拆,避免在没有行为变更需求时改写工作台 | | H1-del | 关闭理由:`allowLegacyTestPrincipal` 仍被 `AllowInMemory && ContextDbHost==""` 的 dev/test 内存测试使用。删除会把这批测试变成未登录 401。保留告警,删除另开、先改测试夹具 | -仍开放:M7、M15、FE,以及能力矩阵的 rdev 剩余、SWBOOT-10、FAV live、D7、首次加载 U1/U3–U7、晋升前置(rebuild / PG / 前端构建 / 403 负例)。SWBOOT-10、FAV live、D7 受红线约束,不能靠直接打开开关算完成。 +仍开放:M7 修订三阶段事务、FE `App.tsx` 拆分、rdev S2/S3 与 P2–P4、首次加载 U1/U3–U7、晋升前置(dev rebuild、治理 PG、`npm test`/`npm run build`)。跨用户会话列表 403 已有 `TestListChatSessions_CrossUserIs403`。`CURRENT.md` 文首 test schema 已改为 52。 + +红线关闭(不是实现): + +- SWBOOT-10:保持 fail-closed stub。未获独立授权前不接真实仓库写入。 +- FAV live:禁止整支 merge;`FAV_DEGRADED_READ=false`;live 需要各阶段至少 7 天观察窗,本轮不放量。 +- D7:合规签字前禁止按留存口径物理删除。盘点已完成,口径仍 `pending-compliance-signoff`。 ## 清单 diff --git a/internal/server/chat_session_ownership_test.go b/internal/server/chat_session_ownership_test.go index 7b5cc149..9bf74e80 100644 --- a/internal/server/chat_session_ownership_test.go +++ b/internal/server/chat_session_ownership_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strconv" "strings" "testing" "time" @@ -114,3 +115,22 @@ func TestChatSessionOwnership_CrossUserHijackDenied_Memory(t *testing.T) { t.Fatalf("victim continued access broken: %d %s", rec5.Code, rec5.Body.String()) } } + +func TestListChatSessions_CrossUserIs403(t *testing.T) { + cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"} + svc := bizdev.MustNewService(t, cfg) + handler := server.NewServer(cfg, svc).Routes() + adminToken := loginTestAdmin(t, handler, cfg) + suffix := time.Now().Format("150405.000") + victimToken := createAndLoginUser(t, handler, adminToken, "list_victim_"+suffix, "pass12345") + attackerToken := createAndLoginUser(t, handler, adminToken, "list_attacker_"+suffix, "pass12345") + victimID := fetchAuthUserID(t, handler, victimToken) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/chat/sessions?user_id="+strconv.FormatInt(victimID, 10), nil) + withBearerAuth(req, attackerToken) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("cross-user session list expected 403, got %d: %s", rec.Code, rec.Body.String()) + } +} diff --git a/web/src/data/storage.ts b/web/src/data/storage.ts index 80b6aa18..32a9ef85 100644 --- a/web/src/data/storage.ts +++ b/web/src/data/storage.ts @@ -13,6 +13,17 @@ import { enrichMessagesWithCategories } from '../engine/categoryHelper'; // v6:修正 NeuroPulse 驳回追问误挂「疲劳寿命」旧答复;对话 LLM 每轮独立工作区 const STORAGE_PREFIX = 'smdf_agent_workbench_v6_'; +// CACHE_SCHEMA 是窗口缓存 JSON 的载荷版本。缺字段或版本不符时丢弃,避免旧结构被当成有效缓存。 +export const CACHE_SCHEMA = 1; + +type VersionedCache = { schema: number; data: T }; + +function isVersionedCache(value: unknown): value is VersionedCache { + if (!value || typeof value !== 'object') return false; + const row = value as { schema?: unknown; data?: unknown }; + return typeof row.schema === 'number' && 'data' in row; +} + export const STORAGE_KEYS = { SESSIONS: `${STORAGE_PREFIX}sessions`, ACTIVE_SESSION: `${STORAGE_PREFIX}active_session`, @@ -171,12 +182,14 @@ function updateWindowLRU(cacheKey: string): void { } export function loadSessionWindowCache(sessionId: string, userId?: number): ChatDayBucket[] | null { - return safeGetItem(getWindowCacheKey(sessionId, userId), null); + const raw = safeGetItem(getWindowCacheKey(sessionId, userId), null); + if (!isVersionedCache(raw) || raw.schema !== CACHE_SCHEMA) return null; + return raw.data; } export function saveSessionWindowCache(sessionId: string, buckets: ChatDayBucket[], userId?: number): void { const cacheKey = getWindowCacheKey(sessionId, userId); - safeSetItem(cacheKey, buckets); + safeSetItem(cacheKey, { schema: CACHE_SCHEMA, data: buckets }); updateWindowLRU(cacheKey); } diff --git a/web/src/data/storage.windowCache.test.ts b/web/src/data/storage.windowCache.test.ts index 688eea75..3d9ca3da 100644 --- a/web/src/data/storage.windowCache.test.ts +++ b/web/src/data/storage.windowCache.test.ts @@ -86,6 +86,16 @@ test('window cache LRU: 驱逐带 userId 前缀的完整 cache key,不留孤 } }); +test('window cache: 无 schema 的旧载荷不当作有效缓存', () => { + const { store, restore } = installMemoryLocalStorage(); + try { + store.set('smdf_chat_window_u7_sess-old', JSON.stringify([{ dayKey: 'stale' }])); + assert.equal(loadSessionWindowCache('sess-old', 7), null); + } finally { + restore(); + } +}); + test('window cache: 读写同一 userId 前缀,跨用户不串读', () => { const { restore } = installMemoryLocalStorage(); try {