Files
Cyrene_v2/internal/services/gateway/ws_role_gate_test.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

405 lines
16 KiB
Go

package gateway
import (
"context"
"encoding/json"
"errors"
"net"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
"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:
//
// 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.
//
// 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.
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 = 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 = 250 * time.Millisecond
// wsRoleGateSettle lets the side effects of an accepted frame land before they
// are asserted to be absent.
wsRoleGateSettle = 200 * time.Millisecond
// wsRoleGateWriteWait bounds a control-frame write (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.
type countingRunner struct {
mu sync.Mutex
calls int
}
func (r *countingRunner) Run(context.Context, string, []model.Message) (*loop.Result, error) {
r.mu.Lock()
r.calls++
r.mu.Unlock()
return &loop.Result{Text: "reply", Steps: 1, StopReason: "stop"}, nil
}
func (r *countingRunner) count() int {
r.mu.Lock()
defer r.mu.Unlock()
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.
type replayHistoryStore struct {
*fakeHistory
mu sync.Mutex
msgs []history.Message
}
func newReplayHistoryStore(msgs ...history.Message) *replayHistoryStore {
return &replayHistoryStore{fakeHistory: &fakeHistory{}, msgs: msgs}
}
func (s *replayHistoryStore) Recent(context.Context, string, int) ([]history.Message, error) {
s.mu.Lock()
defer s.mu.Unlock()
return append([]history.Message(nil), s.msgs...), nil
}
// historyFrameWire is the handshake replay frame decoded for assertions.
type historyFrameWire struct {
Type string `json:"type"`
SessionID string `json:"sessionId"`
Data struct {
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
Thinking string `json:"thinking"`
} `json:"messages"`
} `json:"data"`
}
// errorFrameWire is any {type:"error"} frame decoded for assertions.
type errorFrameWire struct {
Type string `json:"type"`
Data struct {
Code string `json:"code"`
Message string `json:"message"`
} `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.
type roleGateEnv struct {
srv *Server
url string
jwt *auth.JWTService
run *countingRunner
hist *replayHistoryStore
}
func newRoleGateEnv(t *testing.T, transcript []history.Message) *roleGateEnv {
t.Helper()
run := &countingRunner{}
hist := newReplayHistoryStore(transcript...)
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{
Loop: run,
History: hist,
Scheduler: &stubAuditor{res: &orchestration.AuditResult{
AgentID: "sub-1",
Mode: orchestration.AuditFull,
Goal: "子代理的私密目标",
}},
Tasks: &stubTaskOutputs{ev: task.OutputEvent{
ID: "task-1",
Output: task.Output{Stdout: "任务的私密输出"},
}},
})
return &roleGateEnv{srv: srv, url: ts.URL, jwt: jwt, run: run, hist: hist}
}
func (e *roleGateEnv) token(t *testing.T, role auth.Role) string {
t.Helper()
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.
func TestWSHandshakeReplayPerRole(t *testing.T) {
t.Parallel()
env := newRoleGateEnv(t, []history.Message{
{ID: "m1", SessionID: history.MainSessionID, Role: history.RoleUser, Content: "主人的私密提问", Thinking: "不该泄漏的推理", CreatedAt: 1},
{ID: "m2", SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "角色的私密回答", CreatedAt: 2},
})
owner := dialWS(t, env.url, env.token(t, auth.RoleOwner))
defer owner.Close()
admin := dialWS(t, env.url, env.token(t, auth.RoleAdmin))
defer admin.Close()
viewer := dialWS(t, env.url, env.token(t, auth.RoleViewer))
defer viewer.Close()
waitConnCount(t, env.srv, 3)
for _, tc := range []struct {
role auth.Role
conn *websocket.Conn
}{{auth.RoleOwner, owner}, {auth.RoleAdmin, admin}} {
typ, raw := readWire(t, tc.conn, string(tc.role)+" handshake")
if typ != "history" {
t.Fatalf("%s first frame = %q, want the history replay", tc.role, typ)
}
var hf historyFrameWire
if err := json.Unmarshal(raw, &hf); err != nil {
t.Fatalf("%s decode history frame %s: %v", tc.role, raw, err)
}
if hf.SessionID != history.MainSessionID {
t.Fatalf("%s history sessionId = %q, want %q", tc.role, hf.SessionID, history.MainSessionID)
}
if len(hf.Data.Messages) != 2 {
t.Fatalf("%s replayed %d messages, want 2 (%+v)", tc.role, len(hf.Data.Messages), hf.Data.Messages)
}
if got := hf.Data.Messages[0].Content; got != "主人的私密提问" {
t.Fatalf("%s replayed content = %q, want the stored user message", tc.role, got)
}
for i, m := range hf.Data.Messages {
if m.Thinking != "" {
t.Fatalf("%s replayed message[%d] carries thinking %q; it must be stripped", tc.role, i, m.Thinking)
}
}
}
// 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.
if err := viewer.WriteJSON(wsInbound{Type: "chat", Data: json.RawMessage(`{"sessionId":"web","text":"viewer"}`)}); err != nil {
t.Fatalf("viewer write chat: %v", err)
}
if typ, raw := readWire(t, viewer, "viewer handshake"); typ == "history" {
t.Fatalf("viewer received the handshake replay %s; design/11 §7.2 A allows owner/admin only", raw)
}
}
// 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.
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.
observer := dialWS(t, env.url, env.token(t, auth.RoleViewer))
defer observer.Close()
cases := []struct {
name string
frame wsInbound
}{
{"chat", wsInbound{Type: "chat", Data: json.RawMessage(`{"sessionId":"web","text":"替主人发一条"}`)}},
{"interrupt", wsInbound{Type: "interrupt", Data: json.RawMessage(`{"sessionId":"web"}`)}},
{"subagent_ask", wsInbound{Type: "subagent_ask", Data: json.RawMessage(`{"id":"sub-1","mode":"full"}`)}},
{"task_output_query", wsInbound{Type: "task_output_query", Data: json.RawMessage(`{"taskId":"task-1"}`)}},
{"voice_input", wsInbound{Type: "voice_input", Data: json.RawMessage(`{"sessionId":"web","text":"你好"}`)}},
{"mode_switch", wsInbound{Type: "mode_switch", Data: json.RawMessage(`{"mode":"chat"}`)}},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
conn := dialWS(t, env.url, env.token(t, auth.RoleViewer))
defer conn.Close()
if err := conn.WriteJSON(tc.frame); err != nil {
t.Fatalf("write %s: %v", tc.name, err)
}
typ, raw := readWire(t, conn, tc.name)
if typ != "error" {
t.Fatalf("%s reply = %q (%s), want a directed error frame", tc.name, typ, raw)
}
var ef errorFrameWire
if err := json.Unmarshal(raw, &ef); err != nil {
t.Fatalf("%s decode reply %s: %v", tc.name, raw, err)
}
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.
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.
time.Sleep(wsRoleGateSettle)
calls, records := env.run.count(), env.hist.records()
if calls != 0 || len(records) != 0 {
t.Fatalf("viewer frames reached the runner %d time(s) and persisted %d message(s) %+v; a viewer may do neither", calls, len(records), records)
}
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.
func TestWSInboundChatDrivesOwnerTurn(t *testing.T) {
t.Parallel()
env := newRoleGateEnv(t, nil)
owner := dialWS(t, env.url, env.token(t, auth.RoleOwner))
defer owner.Close()
waitConnCount(t, env.srv, 1)
if err := owner.WriteJSON(wsInbound{Type: "chat", Data: json.RawMessage(`{"sessionId":"web","text":"主人问一句"}`)}); err != nil {
t.Fatalf("write chat: %v", err)
}
waitUntil(t, "the owner's chat to drive the runner", func() bool { return env.run.count() == 1 })
waitUntil(t, "the owner's turn to be persisted", func() bool { return len(env.hist.records()) == 2 })
got := env.hist.records()
if got[0].Role != history.RoleUser || got[1].Role != history.RoleAssistant {
t.Fatalf("persisted roles = %q, %q, want user then assistant", got[0].Role, got[1].Role)
}
if got[1].Content != "reply" {
t.Fatalf("persisted reply = %q, want the runner's text", got[1].Content)
}
}
// 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.
func TestWSInboundGateKeepsProtocolFrames(t *testing.T) {
t.Parallel()
env := newRoleGateEnv(t, nil)
viewer := dialWS(t, env.url, env.token(t, auth.RoleViewer))
waitConnCount(t, env.srv, 1)
if err := viewer.WriteJSON(wsInbound{Type: "chat", Data: json.RawMessage(`{"sessionId":"web","text":"x"}`)}); err != nil {
t.Fatalf("write chat: %v", err)
}
if typ, raw := readWire(t, viewer, "probe"); typ != "error" {
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)
}
if typ, raw := readWire(t, viewer, "second probe"); typ != "error" {
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.
ponged := make(chan struct{}, 1)
viewer.SetPongHandler(func(string) error {
select {
case ponged <- struct{}{}:
default:
}
return nil
})
if err := viewer.WriteControl(websocket.PingMessage, nil, time.Now().Add(wsRoleGateWriteWait)); err != nil {
t.Fatalf("write ping: %v", err)
}
_ = viewer.SetReadDeadline(time.Now().Add(wsRoleGateReadWait))
if _, msg, err := viewer.ReadMessage(); err == nil {
t.Fatalf("data frame %s while waiting for the pong", msg)
}
select {
case <-ponged:
default:
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.
func readWire(t *testing.T, conn *websocket.Conn, what string) (string, []byte) {
t.Helper()
_ = conn.SetReadDeadline(time.Now().Add(wsRoleGateReadWait))
_, msg, err := conn.ReadMessage()
if err != nil {
t.Fatalf("%s: read frame: %v", what, err)
}
var env struct {
Type string `json:"type"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("%s: decode frame %s: %v", what, msg, err)
}
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.
func assertNoFrame(t *testing.T, conn *websocket.Conn, what string) {
t.Helper()
_ = conn.SetReadDeadline(time.Now().Add(wsRoleGateNoFrameWait))
_, msg, err := conn.ReadMessage()
if err == nil {
t.Fatalf("%s: unexpected frame %s", what, msg)
}
var netErr net.Error
if !errors.As(err, &netErr) || !netErr.Timeout() {
t.Fatalf("%s: read failed with %v, want a timeout (no frame expected)", what, err)
}
}
// 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.
func waitUntil(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(wsRoleGateReadWait)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(2 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}