feat(gateway,security): WS 上行帧角色门 + 握手回放仅 owner/admin(design/11 §7.2)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人 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:
AskaEth committed 2026-10-03 22:02:16 +08:00
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"}`)})
+21 -4
View File
@@ -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)
}
+45 -1
View File
@@ -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)
}