fix(web): version chat window cache and cover cross-user session list

Drop window-cache payloads that lack a schema field. Prove a non-owner listing another user's sessions is forbidden. Correct the stale test schema note in CURRENT.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-19 09:28:11 +00:00
co-authored by Cursor
parent 2a666df3c1
commit fc32ba3dc3
5 changed files with 58 additions and 4 deletions
+1 -1
View File
@@ -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)
## 焦点
@@ -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`。
## 清单
@@ -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())
}
}
+15 -2
View File
@@ -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<T> = { schema: number; data: T };
function isVersionedCache<T>(value: unknown): value is VersionedCache<T> {
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<ChatDayBucket[] | null>(getWindowCacheKey(sessionId, userId), null);
const raw = safeGetItem<unknown>(getWindowCacheKey(sessionId, userId), null);
if (!isVersionedCache<ChatDayBucket[]>(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);
}
+10
View File
@@ -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 {