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