Files
Cyrene_v2/internal/services/gateway/ws_route.go
T
AskaEth 355f81d04a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 第 2 批 5 个文件注释改中文(只改注释,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」。本批 = WS 上行门与握手回放那批文件:
ws_hub.go / ws_history.go / ws_route.go / ws_role_gate_test.go / voice_signal_test.go
含中文的整行注释 6 → 148;无行尾注释。

校验(判据 = "只改注释、零代码字节改动"):
- git diff -U0 | 排除 +++/--- / 注释行 / 空行 / 行尾注释 → 为空
  (含"把纯空行变动也视为违规"的更严变体,同样为空)
- 独立实现:两侧剥掉注释后逐行比对代码文本 → 5/5 全等
- gofmt -l 干净(改动前同样干净,不存在"顺手格式化")
- go test ./... 全绿;go build/vet 绿;linux/amd64 + darwin/arm64 交叉编译绿
安全约束强度逐句保留:ws_route.go 的「默认拒(非黑名单)/ 删掉它等于放开一切」、
ws_history.go 的「门在 replayHistory 第一条语句 / 扩角色须先登记 design/11 §7.2 /
回放不含 thinking」、ws_role_gate_test.go 的测试名与断言字面量一字符未改。
2026-10-03 22:10:33 +08:00

148 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"encoding/json"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
)
// wsForbiddenCode 是非 owner/admin 连接发送任何上行指令时收到的帧的机器可读
// 错误码(design/11 §7.2 B)。它沿用本文件既有的错误帧形状 ——
// wsEvent{Type: "error", Data: map[string]any} —— 客户端用它已经认识的 code
// 解码,仅凭这个键就能把「你没有这个权限」与处理函数级失败区分开。
const wsForbiddenCode = "forbidden"
// route 按类型分发一个已解码的上行(客户端→服务端)帧。
//
// 角色门(design/11 §7.2 B,主人于 2026-10-03 决策):非 owner/admin 连接
// 不得发送任何应用内指令。该检查是本函数的第一条语句,且默认拒 —— 它刻意
// 不是一张「禁止类型」清单 —— 因此后续里程碑新增的帧类型在有人显式登记
// 之前一律被拒。viewer 的 /ws/chat 连接因此是只读监视器:它跟随会话状态机,
// 但永远无法驱动模型、取消回合、读取子代理上下文或任务输出,也不能输入语音。
// 每个被拒的帧只在那一根连接上得到 {type:"error", data:{code:"forbidden"}}
// 的应答;不广播、不进入任何处理函数,因此不产生模型调用,也不落库。
//
// 放宽此门:viewer 侧的上行帧只有先在设计里给它开口、并登记到 design/11 §7.2
// 之后才允许(「必须先在设计里给它开口并登记白名单」)。下方的一律拒绝正是
// 让「忘记这一步」仍然安全的保障,所以放开某一个类型意味着把它换成一张
// 登记表,而不是删掉它。
//
// 协议级帧(ping/pong/close)不会到达这里 —— gorilla 在 ReadMessage 内部
// 处理 —— 因此这道门不会杀死连接。完全不是 JSON 的帧由 readPump 在分发前
// 应答;它不带类型、触发不了任何东西,所以保留解析层的应答。
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: 用户说「停下/算了」→ 取消在飞回合并清空该会话的
// 待处理队列,使下一条消息从干净状态开始。
c.h.handleInterrupt(in.Data)
case "voice_input":
// 语音经注入的 STT→chat→TTS 管线处理。没有 voice service 时回一个
// 明确的错误,而不是把帧丢掉。音频回复作为 voice_output 事件发在
// 发起请求的那根连接上。
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 是 subagent_ask 帧的解码载荷。Mode 选择审计窗口
// (""/full/tail/keyword);n 界定 tail 查询的条数;query 是 keyword 查询的
// 关键词。
type subagentAskRequest struct {
ID string `json:"id"`
Mode string `json:"mode"`
N int `json:"n"`
Query string `json:"query"`
}
// taskOutputRequest 是 task_output_query 帧的解码载荷。
type taskOutputRequest struct {
TaskID string `json:"taskId"`
}
// handleSubagentAsk 审计某个子代理的上下文,并以携带真实上下文窗口的
// ws.subagent_event 帧回复。未注入 auditor(gateway 没接 scheduler)时回一个
// 明确的错误,而不是编造一个 "pending" 快照。
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 取回 taskId 的真实任务输出,并以 ws.task_output 帧回复。
// 未注入 task store 时回一个明确的错误,而不是空的 {}。仍在运行的任务返回迄今
// 已捕获的输出。
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 为上行业务拒绝的日志记录标注连接角色。claims 为 nil 的连接
// (单元测试构造的 fail-closed 裸连接)记为 "none",而不是解引用空指针。
func claimRole(claims *auth.Claims) string {
if claims == nil {
return "none"
}
return string(claims.Role)
}