Files
Cyrene_v2/internal/services/gateway/ws_history.go
T
AskaEth 770a83c3fb
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway,security): WS 上行帧角色门 + 握手回放仅 owner/admin(design/11 §7.2)
主人 2026-10-03 拍板"这一类要收紧"后,补齐 §7.1 下行门之外的两处同类缺口:
viewer 是只读监控档,却能通过 WS 拿到内容、甚至发起写入。

A. 握手 history 回放仅 owner/admin(ws_history.go)
   连接建立时回放主会话最近 50 条消息正文,此前对任意角色照发 ⇒ viewer 一连上就
   拿走主人对话内容。门放在 hub.replayHistory 的第一行 —— 它是唯一发出 history 帧的
   函数,将来多一个调用者也绕不过去。

B. 上行 JSON 帧按角色授权(ws_route.go,fail-closed)
   此前 route 完全没有角色检查。现在 route 的第一条语句即:非 owner/admin → 回定向
   {type:"error",data:{code:"forbidden"}} 并 return,因此"不广播、不驱动模型、不落库、
   不返回任何内容"是结构性的,而不是每个 handler 各自记得检查。写成默认拒而非黑名单 ——
   chat / interrupt / subagent_ask / task_output_query / voice_input / mode_switch 以及
   将来任何新帧都自动被拒。协议级帧(ping/pong/close)由 gorilla 在 ReadMessage 内处理,
   不受影响(已实测:被拒后 ping→pong 正常、close 后服务端注销连接)。
   被修掉的真实越权:viewer 发 chat 会真的驱动模型并把消息落库;subagent_ask /
   task_output_query 的定向应答会绕过 §7.1 下行门,把子代理上下文/任务输出回给 viewer。

契约:Docs/api-client.md → v0.49(对 viewer 收紧;owner/admin 逐帧形状不变)

验证:go build/vet 绿;go test ./... = 58 包 ok / 0 FAIL;-race ./internal/services/gateway/...
ok;linux/amd64 + darwin/arm64 交叉编译绿;新测试 -count=3 稳定。
真 httptest WS + 真 JWT 逐帧实测:viewer 收不到 history 回放、上行一律定向 forbidden
且 runner 计数 = 0 / 落库 = 0;owner 侧 chat 驱动 runner = 1 且落库 2 条。
变异验证:① 去掉 A 的门 → viewer 收到 {"type":"history",...,"content":"主人的私密提问"},FAIL;
② 去掉 B 的门 → viewer 帧驱动 runner 1 次并落库 2 条、且直接收到 subagent_event / task_output,
FAIL。均已还原;且撤掉 B 时 A 的测试仍独立 PASS(两把门不互相代偿)。

遗留(如实登记):非 JSON 的上行字节不经过 route 的角色门(零副作用:不带 type、不触发
handler、不落库);admin 上行未单独测一格(与 owner 共用同一分支)。
2026-10-03 22:02:16 +08:00

52 lines
2.1 KiB
Go

package gateway
import (
"context"
)
// wsReplayLimit is the number of most-recent messages replayed to a connecting
// client. It is intentionally small so a reconnect is cheap; the frontend pulls
// the full active-session history via REST (design/16 §4.1) and the replay is
// only there to give a brand-new connection immediate context.
const wsReplayLimit = 50
// replayHistory sends the most recent messages for sessionID to a single,
// freshly-registered connection as a ws "history" event.
//
// The replay carries the main session's message bodies, so it is owner/admin only
// (design/11 §7.2 A, decided by the owner on 2026-10-03). ownerAdminClaims is the
// same predicate the proactive push uses, so 「who may be handed the owner's
// conversation unasked」 is answered in one place instead of per caller. A viewer
// connection completes the handshake with no replay at all: its read-only window
// is the REST monitoring surface (design/11 §7.1 「语义澄清」), not a push.
//
// The gate lives here rather than at the register/replay call site in
// ws_hub_conn.go on purpose: this is the only function that emits the replay, so
// no future caller can reach it without the check. A role the owner later wants
// to serve the replay to has to be registered in design/11 §7.2 first, exactly
// like an inbound frame (see ws_route.go).
//
// It is best-effort: a nil store or a read error skips the replay silently.
// Thinking is NOT included (design/16 §6), so the frame never leaks
// chain-of-thought even to owner/admin.
func (h *hub) replayHistory(c *wsConn, sessionID string) {
if !ownerAdminClaims(c.claims) {
return
}
if h.history == nil {
return
}
msgs, err := h.history.Recent(context.Background(), sessionID, wsReplayLimit)
if err != nil {
h.log.Warn("history replay", "err", err, "session", sessionID)
return
}
out := make([]historyMessage, 0, len(msgs))
for _, m := range msgs {
wire := toHistoryMessage(m)
wire.Thinking = ""
out = append(out, wire)
}
c.enqueueJSON(wsEvent{Type: "history", SessionID: sessionID, Data: map[string]any{"messages": out}})
}