docs(gateway): gateway 第 2 批 5 个文件注释改中文(只改注释,代码零字节改动)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人 2026-10-03 拍板「注释一律中文」。本批 = WS 上行门与握手回放那批文件:
ws_hub.go / ws_history.go / ws_route.go / ws_role_gate_test.go / voice_signal_test.go
含中文的整行注释 6 → 148;无行尾注释。

校验(判据 = "只改注释、零代码字节改动"):
- git diff -U0 | 排除 +++/--- / 注释行 / 空行 / 行尾注释 → 为空
  (含"把纯空行变动也视为违规"的更严变体,同样为空)
- 独立实现:两侧剥掉注释后逐行比对代码文本 → 5/5 全等
- gofmt -l 干净(改动前同样干净,不存在"顺手格式化")
- go test ./... 全绿;go build/vet 绿;linux/amd64 + darwin/arm64 交叉编译绿
安全约束强度逐句保留:ws_route.go 的「默认拒(非黑名单)/ 删掉它等于放开一切」、
ws_history.go 的「门在 replayHistory 第一条语句 / 扩角色须先登记 design/11 §7.2 /
回放不含 thinking」、ws_role_gate_test.go 的测试名与断言字面量一字符未改。
This commit is contained in:
AskaEth committed 2026-10-03 22:10:33 +08:00
1 parent fe1ff33115
commit 355f81d04a
5 files changed
+149 -181

No files matched your search

+63 -78
View File
@@ -19,40 +19,35 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/task"
)
// This file locks design/11 §7.2 — the two remaining places where a `viewer`
// (the read-only monitoring role) used to reach beyond its lane through
// /ws/chat, both found while implementing the §7.1 downlink gate:
// 本文件锁定 design/11 §7.2 —— `viewer`(只读监控角色)过去经由 /ws/chat
// 越界的最后两处,都是实现 §7.1 下行门时发现的:
//
// A. the handshake history replay (ws_history.go) carries the main session's
// message bodies, so it is owner/admin only;
// B. every inbound (client→server) JSON frame (ws_route.go) is denied by
// default for a non owner/admin connection, so a viewer can follow the
// session state machine but can never send an instruction.
// A. 握手时的历史回放(ws_history.go)携带主会话的消息正文,因此仅限
// owner/admin;
// B. 每一个上行(客户端→服务端)JSON 帧(ws_route.go)对非 owner/admin
// 连接默认拒,因此 viewer 可以跟随会话状态机,但永远发不出指令。
//
// Together with the §7.1 downlink gate they pin the combined contract:
// `viewer` = may connect, may read its 12 read-only REST monitoring endpoints,
// may receive the 7 state-machine signals — and gets and sends nothing else over
// WS. The connections and tokens are real (httptest server + signed JWTs), so a
// mistake in the routing tables cannot hide behind a fake.
// 与 §7.1 下行门一起,它们钉住了合并后的契约:
// `viewer` = 可以连接、可以读它那 12 个只读 REST 监控端点、可以接收
// 7 个状态机信号 —— 除此之外在 WS 上收不到也发不出任何东西。这里的连接与
// 令牌都是真的(httptest server + 签名 JWT),所以路由表里的错误无法藏在
// 假实现后面。
const (
// wsRoleGateReadWait bounds a read that is expected to produce a frame.
// gorilla corrupts a connection after a read timeout, so every connection
// here performs at most one timeout read, always as its last read.
// wsRoleGateReadWait 界定一次预期会产出帧的读取。gorilla 在读超时后会
// 弄坏连接,因此这里每条连接至多做一次超时读取,且总是作为最后一次读。
wsRoleGateReadWait = 3 * time.Second
// wsRoleGateNoFrameWait is the window in which a frame that must not exist
// would have arrived (a directed reply is queued synchronously by the
// handler, so an idle window this long is conclusive).
// wsRoleGateNoFrameWait 是「本不该存在的帧」若存在就会到达的窗口
// (定向回复由处理函数同步入队,所以这么长的空转窗口即已足够判定)。
wsRoleGateNoFrameWait = 250 * time.Millisecond
// wsRoleGateSettle lets the side effects of an accepted frame land before they
// are asserted to be absent.
// wsRoleGateSettle 让被接受帧的副作用先落地,再断言它们不存在。
wsRoleGateSettle = 200 * time.Millisecond
// wsRoleGateWriteWait bounds a control-frame write (ping / close).
// wsRoleGateWriteWait 界定一次控制帧写入(ping / close)。
wsRoleGateWriteWait = 2 * time.Second
)
// countingRunner is the Chatter seam with a call counter: design/11 §7.2 B has to
// prove that a viewer's `chat` frame never reaches the model, so "was the runner
// driven" must be observable rather than inferred from a broadcast.
// countingRunner 是带调用计数的 Chatter 接缝:design/11 §7.2 B 必须证明
// viewer 的 `chat` 帧不会到达模型,所以「runner 是否被驱动过」要可观测,
// 而不能从广播反推。
type countingRunner struct {
mu sync.Mutex
calls int
@@ -71,9 +66,9 @@ func (r *countingRunner) count() int {
return r.calls
}
// replayHistoryStore serves the handshake replay with a fixed transcript while
// inheriting fakeHistory's Append recording, so one store both makes the replay
// deterministic and reports what an accepted `chat` frame persisted.
// replayHistoryStore 用固定转录内容提供握手回放,同时继承 fakeHistory 的
// Append 记录,因此一个 store 既让回放确定,又能报告被接受的 `chat` 帧持久化了
// 什么。
type replayHistoryStore struct {
*fakeHistory
mu sync.Mutex
@@ -90,7 +85,7 @@ func (s *replayHistoryStore) Recent(context.Context, string, int) ([]history.Mes
return append([]history.Message(nil), s.msgs...), nil
}
// historyFrameWire is the handshake replay frame decoded for assertions.
// historyFrameWire 是解码后用于断言的握手回放帧。
type historyFrameWire struct {
Type string `json:"type"`
SessionID string `json:"sessionId"`
@@ -103,7 +98,7 @@ type historyFrameWire struct {
} `json:"data"`
}
// errorFrameWire is any {type:"error"} frame decoded for assertions.
// errorFrameWire 是解码后用于断言的任意 {type:"error"} 帧。
type errorFrameWire struct {
Type string `json:"type"`
Data struct {
@@ -112,11 +107,10 @@ type errorFrameWire struct {
} `json:"data"`
}
// roleGateEnv wires a gateway for these tests: a counting runner (so a leaked
// inbound frame is visible as a model call), a store that replays a transcript and
// records persistence, and the sub-agent/task seams so a leaked subagent_ask or
// task_output_query would produce a real, assertable content frame instead of a
// handler-level error.
// roleGateEnv 为这些测试接线一个 gateway:带计数的 runner(使泄漏的上行帧
// 表现为一次模型调用)、会回放转录并记录持久化的 store,以及子代理/任务的
// 接缝,使泄漏的 subagent_ask 或 task_output_query 会产生一个真实且可断言的
// 内容帧,而不是处理函数级错误。
type roleGateEnv struct {
srv *Server
url string
@@ -150,12 +144,10 @@ func (e *roleGateEnv) token(t *testing.T, role auth.Role) string {
return signAny(t, e.jwt, "acct", role)
}
// TestWSHandshakeReplayPerRole locks design/11 §7.2 A: the handshake history
// replay holds the main session's message bodies, so only owner/admin connections
// receive it. A leaked replay is always the FIRST frame on a connection — it is
// queued during the upgrade, before the read pump starts and before any reply —
// so reading the viewer's first frame turns "no replay" into an exact assertion
// instead of a guessed idle window.
// TestWSHandshakeReplayPerRole 锁定 design/11 §7.2 A:握手历史回放持有主会话的
// 消息正文,因此只有 owner/admin 连接会收到它。泄漏的回放永远是连接上的第一个
// 帧 —— 它在 upgrade 期间就已入队,早于读泵启动、也早于任何回复 —— 所以读
// viewer 的第一帧就把「没有回放」变成精确断言,而不是靠猜一个空转窗口。
func TestWSHandshakeReplayPerRole(t *testing.T) {
t.Parallel()
env := newRoleGateEnv(t, []history.Message{
@@ -199,8 +191,8 @@ func TestWSHandshakeReplayPerRole(t *testing.T) {
}
}
// The viewer is probed so that a frame is guaranteed to come back (the §7.2 B
// refusal); if the replay leaked it would be ahead of that reply.
// 这里主动探一次 viewer,以保证一定有帧回来(§7.2 B 的拒绝);若回放泄漏,
// 它会排在那条回复之前。
if err := viewer.WriteJSON(wsInbound{Type: "chat", Data: json.RawMessage(`{"sessionId":"web","text":"viewer"}`)}); err != nil {
t.Fatalf("viewer write chat: %v", err)
}
@@ -209,16 +201,15 @@ func TestWSHandshakeReplayPerRole(t *testing.T) {
}
}
// TestWSInboundDeniedForViewer locks design/11 §7.2 B: a non owner/admin
// connection may not send any in-app instruction. Every rejected frame gets a
// connection-directed {type:"error", data:{code:"forbidden"}} and nothing else —
// no content frame, no broadcast, no model call, no persisted message.
// TestWSInboundDeniedForViewer 锁定 design/11 §7.2 B:非 owner/admin 连接不得
// 发送任何应用内指令。每个被拒的帧都得到定向到该连接的
// {type:"error", data:{code:"forbidden"}},别的什么都没有 —— 没有内容帧、
// 没有广播、没有模型调用、没有持久化消息。
func TestWSInboundDeniedForViewer(t *testing.T) {
t.Parallel()
env := newRoleGateEnv(t, nil)
// A second viewer connection observes that the refusals are directed at the
// sender: a broadcast refusal would land here too.
// 第二条 viewer 连接用于观察拒绝是指向发送方的:若拒绝走广播,这里也会收到。
observer := dialWS(t, env.url, env.token(t, auth.RoleViewer))
defer observer.Close()
@@ -252,15 +243,15 @@ func TestWSInboundDeniedForViewer(t *testing.T) {
if ef.Data.Code != wsForbiddenCode {
t.Fatalf("%s error code = %q, want %q (frame=%s)", tc.name, ef.Data.Code, wsForbiddenCode, raw)
}
// For subagent_ask / task_output_query a leak would be a directed
// subagent_event / task_output reply carrying a sub-agent's context or
// a task's output, so assert the refusal is the whole answer.
// 对 subagent_ask / task_output_query 而言,泄漏会是一条定向的
// subagent_event / task_output 回复,携带子代理上下文或任务输出,
// 因此断言拒绝就是全部应答。
assertNoFrame(t, conn, tc.name+" follow-up")
})
}
// The `chat` case is the one that used to drive the model and persist the
// message; give its (refused) side effects a moment to not happen.
// `chat` 这一例正是过去会驱动模型并持久化消息的那个;给它(已被拒的)
// 副作用一点时间,确认它们没有发生。
time.Sleep(wsRoleGateSettle)
calls, records := env.run.count(), env.hist.records()
if calls != 0 || len(records) != 0 {
@@ -269,10 +260,9 @@ func TestWSInboundDeniedForViewer(t *testing.T) {
assertNoFrame(t, observer, "observer viewer (a refusal must not be broadcast)")
}
// TestWSInboundChatDrivesOwnerTurn is the owner half of design/11 §7.2 B: the very
// frame a viewer is refused (`chat`) still drives the model and persists the turn
// for an owner, so the gate is proven to be a role gate rather than a broken chat
// path.
// TestWSInboundChatDrivesOwnerTurn 是 design/11 §7.2 B 的主人侧对照:被拒给 viewer
// 的那个帧(`chat`)对主人依然会驱动模型并持久化回合,从而证明这道门确实是角色
// 门,而不是 chat 路径坏了。
func TestWSInboundChatDrivesOwnerTurn(t *testing.T) {
t.Parallel()
env := newRoleGateEnv(t, nil)
@@ -295,10 +285,9 @@ func TestWSInboundChatDrivesOwnerTurn(t *testing.T) {
}
}
// TestWSInboundGateKeepsProtocolFrames proves the inbound gate cannot kill a
// connection (design/11 §7.2 B 「协议级帧不受影响」): after a refusal the viewer
// still completes a ping/pong round trip and a normal close, and the server
// deregisters it.
// TestWSInboundGateKeepsProtocolFrames 证明上行门不会杀死连接
// (design/11 §7.2 B「协议级帧不受影响」):被拒一次之后,viewer 仍能完成
// ping/pong 往返和正常关闭,服务端也会注销它。
func TestWSInboundGateKeepsProtocolFrames(t *testing.T) {
t.Parallel()
env := newRoleGateEnv(t, nil)
@@ -312,8 +301,8 @@ func TestWSInboundGateKeepsProtocolFrames(t *testing.T) {
t.Fatalf("probe reply = %q (%s), want the forbidden error frame", typ, raw)
}
// A second refusal round trip proves the read pump survived the first: the
// gate returns before any handler, so the connection is untouched.
// 第二次被拒的往返证明读泵在第一轮之后依然存活:门在任何处理函数之前就返回,
// 连接未被触动。
if err := viewer.WriteJSON(wsInbound{Type: "interrupt", Data: json.RawMessage(`{"sessionId":"web"}`)}); err != nil {
t.Fatalf("write interrupt after refusal: %v", err)
}
@@ -321,10 +310,9 @@ func TestWSInboundGateKeepsProtocolFrames(t *testing.T) {
t.Fatalf("second probe reply = %q (%s), want the forbidden error frame", typ, raw)
}
// A client ping is answered by the server's default ping handler. The pong is
// consumed by the client read below, so the handler is what reports it. This
// is the connection's single timeout read (gorilla corrupts a connection after
// a read deadline), hence it comes after every assertion on the read side.
// 客户端 ping 由服务端默认的 ping 处理函数应答。pong 会被下面的客户端读取
// 消费掉,因此由该处理函数来报告它。这是本连接唯一的一次超时读取
// (gorilla 在读截止时间过后会弄坏连接),所以它排在读侧所有断言之后。
ponged := make(chan struct{}, 1)
viewer.SetPongHandler(func(string) error {
select {
@@ -346,16 +334,15 @@ func TestWSInboundGateKeepsProtocolFrames(t *testing.T) {
t.Fatal("the server never answered the ping; the gate broke the protocol path")
}
// A normal close still deregisters the connection.
// 正常关闭依然会注销该连接。
if err := viewer.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), time.Now().Add(wsRoleGateWriteWait)); err != nil {
t.Fatalf("write close: %v", err)
}
waitConnCount(t, env.srv, 0)
}
// readWire reads one frame, returning its type plus the raw bytes (so a caller can
// decode the type-specific payload). It fails the test when no frame arrives
// before the deadline.
// readWire 读取一个帧,返回它的类型与原始字节(以便调用方解码按类型区分的
// 载荷)。若在截止时间前没有帧到达则让测试失败。
func readWire(t *testing.T, conn *websocket.Conn, what string) (string, []byte) {
t.Helper()
_ = conn.SetReadDeadline(time.Now().Add(wsRoleGateReadWait))
@@ -372,9 +359,8 @@ func readWire(t *testing.T, conn *websocket.Conn, what string) (string, []byte)
return env.Type, msg
}
// assertNoFrame asserts that nothing further arrives on conn within a short
// window. It must be the connection's last read: gorilla leaves a connection
// corrupt after a read deadline expires.
// assertNoFrame 断言短窗口内 conn 上不会再有任何东西到达。它必须是该连接的最后
// 一次读取:读截止时间到期后 gorilla 会留下一个已损坏的连接。
func assertNoFrame(t *testing.T, conn *websocket.Conn, what string) {
t.Helper()
_ = conn.SetReadDeadline(time.Now().Add(wsRoleGateNoFrameWait))
@@ -388,9 +374,8 @@ func assertNoFrame(t *testing.T, conn *websocket.Conn, what string) {
}
}
// waitUntil polls cond until it holds or the deadline passes, so a test can assert
// on an asynchronous turn (run → persist) without a fixed sleep that could pass
// vacuously.
// waitUntil 轮询 cond 直到它成立或超过截止时间,使测试能对异步回合(run → persist)
// 做断言,而不必用一段可能空跑通过的固定 sleep。
func waitUntil(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(wsRoleGateReadWait)