主人 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 共用同一分支)。
52 lines
2.1 KiB
Go
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}})
|
|
}
|