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 共用同一分支)。
This commit is contained in:
1 parent
bc0047b449
commit
770a83c3fb
5 files changed
+481
-12
No files matched your search
@@ -138,7 +138,9 @@ func TestHubVoiceOutput_SilentSignal(t *testing.T) {
|
||||
t.Parallel()
|
||||
hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好", Steps: 1, StopReason: "stop"}}, nil)
|
||||
hb.voice = &signalVoice{text: "你好", rung: "silent"}
|
||||
c := newFakeConn(hb, 32)
|
||||
// voice_input is an owner-facing instruction since design/11 §7.2 B, so the
|
||||
// connection that drives it has to carry owner/admin claims.
|
||||
c := newOwnerConn(hb, 32)
|
||||
hb.register(c)
|
||||
|
||||
c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"audio":"aGVsbG8=","format":"wav"}`)})
|
||||
@@ -163,7 +165,7 @@ func TestHubVoiceOutput_SuccessOmitsProvider(t *testing.T) {
|
||||
audio: voice.Audio{Format: "wav", Data: []byte("audio-bytes")},
|
||||
rung: "edge-tts",
|
||||
}
|
||||
c := newFakeConn(hb, 32)
|
||||
c := newOwnerConn(hb, 32)
|
||||
hb.register(c)
|
||||
|
||||
c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"audio":"aGVsbG8=","format":"wav"}`)})
|
||||
|
||||
@@ -11,11 +11,28 @@ import (
|
||||
const wsReplayLimit = 50
|
||||
|
||||
// replayHistory sends the most recent messages for sessionID to a single,
|
||||
// freshly-registered connection as a ws "history" event. 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 to a connection whose
|
||||
// role the gateway did not restrict on upgrade.
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
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)
|
||||
}
|
||||
@@ -4,11 +4,45 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
|
||||
)
|
||||
|
||||
// route dispatches one inbound frame by its type.
|
||||
// wsForbiddenCode is the machine-readable code of the frame a non owner/admin
|
||||
// connection gets for any inbound instruction (design/11 §7.2 B). It rides the
|
||||
// file's existing error-frame shape — wsEvent{Type: "error", Data: map[string]any}
|
||||
// — so the client decodes it with the code it already knows, and distinguishes
|
||||
// 「你没有这个权限」 from a handler-level failure by the key alone.
|
||||
const wsForbiddenCode = "forbidden"
|
||||
|
||||
// route dispatches one decoded inbound (client→server) frame by its type.
|
||||
//
|
||||
// Role gate (design/11 §7.2 B, decided by the owner on 2026-10-03): a connection
|
||||
// that is not owner/admin may not send ANY in-app instruction. The check is the
|
||||
// first statement here and it denies by default — it is deliberately NOT a list of
|
||||
// forbidden types — so a frame type added by a later milestone is refused until
|
||||
// someone registers it on purpose. A viewer's /ws/chat connection is therefore a
|
||||
// read-only monitor: it follows the session state machine but can never drive the
|
||||
// model, cancel a turn, read a sub-agent's context or a task's output, or feed
|
||||
// voice input. Each refused frame is answered on that one connection with
|
||||
// {type:"error", data:{code:"forbidden"}}; nothing is broadcast, no handler runs,
|
||||
// so no model call is made and nothing is persisted.
|
||||
//
|
||||
// Widening this: a viewer-side inbound frame may be allowed only after it is given
|
||||
// an opening and registered in design/11 §7.2 (「必须先在设计里给它开口并登记白名单」).
|
||||
// The blanket deny below is what makes forgetting that step safe, so allowing one
|
||||
// means replacing it with a registry, not deleting it.
|
||||
//
|
||||
// Protocol-level frames (ping/pong/close) never reach here — gorilla handles them
|
||||
// inside ReadMessage — so the gate cannot kill a connection. A frame that is not
|
||||
// JSON at all is answered by readPump before dispatch; it carries no type and can
|
||||
// trigger nothing, which is why it keeps its parsing-level answer.
|
||||
func (c *wsConn) route(in wsInbound) {
|
||||
if !ownerAdminClaims(c.claims) {
|
||||
c.h.log.Debug("ws inbound rejected", "type", in.Type, "role", claimRole(c.claims))
|
||||
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"code": wsForbiddenCode}})
|
||||
return
|
||||
}
|
||||
switch in.Type {
|
||||
case "mode_switch":
|
||||
c.publishPublic("ws.mode_switch", rawData(in.Data))
|
||||
@@ -110,3 +144,13 @@ func (c *wsConn) handleTaskOutputQuery(in wsInbound) {
|
||||
"status": string(ev.Status),
|
||||
}})
|
||||
}
|
||||
|
||||
// claimRole labels a connection's role for the inbound-rejection log record. A
|
||||
// connection with nil claims (the fail-closed bare connection a unit test builds)
|
||||
// logs as "none" instead of dereferencing the nil pointer.
|
||||
func claimRole(claims *auth.Claims) string {
|
||||
if claims == nil {
|
||||
return "none"
|
||||
}
|
||||
return string(claims.Role)
|
||||
}
|
||||
Reference in new issue
Block a user