主人 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 共用同一分支)。
157 lines
6.2 KiB
Go
157 lines
6.2 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
|
|
)
|
|
|
|
// 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))
|
|
case "chat":
|
|
c.publishPublic(wsChatSubject, rawData(in.Data))
|
|
case "interrupt":
|
|
// design/15 §3: user says "停下/算了" → cancel the in-flight turn and
|
|
// clear the session's pending queue, so the next message starts fresh.
|
|
c.h.handleInterrupt(in.Data)
|
|
case "voice_input":
|
|
// Voice routes through the injected STT→chat→TTS pipeline. Without a
|
|
// voice service it replies with a clear error rather than dropping the
|
|
// frame. The audio reply is delivered as a voice_output event on the
|
|
// requesting connection.
|
|
c.handleVoiceInput(in)
|
|
case "subagent_ask":
|
|
c.handleSubagentAsk(in)
|
|
case "task_output_query":
|
|
c.handleTaskOutputQuery(in)
|
|
default:
|
|
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "unknown event type"}})
|
|
}
|
|
}
|
|
|
|
// subagentAskRequest is the decoded payload of a subagent_ask frame. Mode selects
|
|
// the audit window (""/full/tail/keyword); n bounds a tail query; query is the
|
|
// keyword for a keyword query.
|
|
type subagentAskRequest struct {
|
|
ID string `json:"id"`
|
|
Mode string `json:"mode"`
|
|
N int `json:"n"`
|
|
Query string `json:"query"`
|
|
}
|
|
|
|
// taskOutputRequest is the decoded payload of a task_output_query frame.
|
|
type taskOutputRequest struct {
|
|
TaskID string `json:"taskId"`
|
|
}
|
|
|
|
// handleSubagentAsk audits a sub-agent's context and replies with a
|
|
// ws.subagent_event frame carrying the real context window. When no auditor is
|
|
// injected (gateway not wired with a scheduler) it replies with a clear error
|
|
// rather than a fabricated "pending" snapshot.
|
|
func (c *wsConn) handleSubagentAsk(in wsInbound) {
|
|
if c.h.auditor == nil {
|
|
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "subagent audit unavailable"}})
|
|
return
|
|
}
|
|
var req subagentAskRequest
|
|
_ = json.Unmarshal(in.Data, &req)
|
|
if req.ID == "" {
|
|
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "subagent id required"}})
|
|
return
|
|
}
|
|
mode := orchestration.AuditMode(req.Mode)
|
|
if mode == "" {
|
|
mode = orchestration.AuditFull
|
|
}
|
|
res, err := c.h.auditor.AuditContext(context.Background(), req.ID, mode, req.N, req.Query)
|
|
if err != nil {
|
|
c.h.log.Debug("subagent audit", "id", req.ID, "err", err)
|
|
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": err.Error()}})
|
|
return
|
|
}
|
|
c.enqueueJSON(wsEvent{Type: "subagent_event", Data: res})
|
|
}
|
|
|
|
// handleTaskOutputQuery fetches the real task output for taskId and replies with
|
|
// a ws.task_output frame. When no task store is injected it replies with a clear
|
|
// error rather than an empty {}. A task that is still running returns the output
|
|
// captured so far.
|
|
func (c *wsConn) handleTaskOutputQuery(in wsInbound) {
|
|
if c.h.taskOutputs == nil {
|
|
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "task output unavailable"}})
|
|
return
|
|
}
|
|
var req taskOutputRequest
|
|
_ = json.Unmarshal(in.Data, &req)
|
|
if req.TaskID == "" {
|
|
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": "task id required"}})
|
|
return
|
|
}
|
|
ev, err := c.h.taskOutputs.TaskOutput(context.Background(), req.TaskID)
|
|
if err != nil {
|
|
c.h.log.Debug("task output query", "id", req.TaskID, "err", err)
|
|
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"message": err.Error()}})
|
|
return
|
|
}
|
|
text := ev.Output.Stdout
|
|
if ev.Output.Stderr != "" {
|
|
if text != "" {
|
|
text += "\n"
|
|
}
|
|
text += ev.Output.Stderr
|
|
}
|
|
c.enqueueJSON(wsEvent{Type: "task_output", Data: map[string]any{
|
|
"taskId": ev.ID,
|
|
"output": text,
|
|
"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)
|
|
}
|