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

+7 -5
View File
@@ -110,7 +110,7 @@
- Web 端镜像实现在 `frontend/web/src/lib/enums.ts`(`labelFor(kind, value)` 对未知值返回原值),权威仍以本端点为准。
- **反漂移保证(后端)**:后端有测试断言「每个内置适配器的 `Name()` 都在 `platforms` 表内」「`moods` 与 persona 情绪状态机的内置状态一致」「`modes` 与前端 `MODES` 九模式一致」——漏登记即 CI FAIL,客户端拿到的表不会滞后于代码。
> **契约版本 `v0.48`**(**WS 下行帧的角色门(design/11 §7.1)**:hub 的下行现在是**按帧类型分档**的——只有 7 个**状态机信号**(`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full` / `session_read`,**均不含任何内容**)继续发给**所有已认证连接**(含 `viewer`),**其余全部下行帧**(流式内容 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` / `stream_chunk` / `stream_end` / `thinking`;持久化 `message_persisted` / `message_edited` / `message_deleted` / `channel_persisted`;系统内部 `graph_update` / `subagent_event` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`;私有状态 `mood` / `device_update` / `reminder`)**改为仅发 `owner` + `admin`**)。**对 `viewer` 是收紧**:此前它连上 `/ws/chat` 就能收到角色回复正文与推理内容,现在收不到;**`owner` / `admin` 行为不变**;**无端点、无字段形状变更**——`viewer` 的 12 条只读监控 REST 端点(`viewerAuth`,v0.40)与 `/ws/chat` 的连接鉴权都**照旧**;**新帧类型默认只发 owner/admin**(fail-closed)。见 §3 开头的「下行帧的角色门」与 v0.48 变更记录。**上一版 `v0.47`**:(**`graph_update` 子代理泳道根节点新增可选 `props.persona`**:派发子代理时若指定了目标角色,该分支根节点的 `props` 就多一个 `persona` 键,标注它**正在扮演的角色**——用途是让流程图能显示「这个子代理是以谁的身份在替班」,而不只是子代理 id;**纯增量**:不带角色派发时该键不出现,老客户端忽略未知键即可,见 §3.5 与 v0.47 变更记录)。**上一版 `v0.46`**:(**`PATCH /api/v1/sessions/{id}` 的 `personaId` 由「可重新绑定该会话的角色」改为「出现即 `400`」**:**会话角色在创建时确定、之后不可变**,要换角色请新开会话(design/22 §8-1)——**明确拒绝**而非静默忽略/200 空操作;**键出现**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)一律 `400 bad_request` 且消息点名 `personaId`,该检查**先于**会话存在性检查(未知会话 + `personaId` 也回 `400` 而不是 `404`)。**破坏性**:按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色 / 显式 `""` 解绑」的客户端必须改为**新建会话**选角色;`title`/`pinned` 行为与 `POST /sessions {personaId}`(创建时绑定)**完全不变**,不再有 `501/200 {personaId}` 的 PATCH 路径)。上一版 `v0.45`:`PUT /admin/channels/{platform}/config` 的 `personaId` 由「省略/`""` 都 = 解绑」修订为三态(**省略(或 `null`)= 保留已存绑定**、**显式 `""` = 解绑**、**非空 id = 绑定**,未知角色仍 `400`)。
> **契约版本 `v0.49`**(**WS 握手回放与上行帧的角色门(design/11 §7.2)**:补上 §7.1 下行门之外的**同一类缺口的另两处**,对 `viewer` **继续收紧**,`owner` / `admin` 行为不变,**无端点、无字段形状变更**——① **握手 `history` 回放改为仅 `owner` + `admin`**:此前任意角色一连上 `/ws/chat` 就会收到主会话最近 50 条消息正文(`thinking` 已剥离,但**正文本身**就是主人的对话),`viewer` 从此**不收**;② **上行 JSON 帧按角色授权(fail-closed)**:非 `owner`/`admin` 连接发来的**任何**上行 JSON 帧一律拒绝,**定向**(只回发送者)回 `{type:"error",data:{code:"forbidden"}}`,**不广播、不驱动模型、不落库、不返回任何内容**——被拒帧含 `chat`(此前 `viewer` 可**真的驱动模型并落库**)、`interrupt`、`subagent_ask` / `task_output_query`(其应答是**定向**帧、能绕过 §7.1 下行门把子代理上下文/任务输出直接回给 `viewer`)、`voice_input`、`mode_switch`,且**未登记的新上行帧默认拒**;**协议级帧(ping/pong、close)不受影响**。③ **合口径**:`viewer` = **可连接、可读 12 条只读监控 REST(v0.40)、可收 7 个状态机信号帧(v0.48)**;**除此以外 WS 上拿不到任何内容、也发不出任何指令**。见 §3 开头的「上行帧与握手回放的角色门」与 v0.49 变更记录。**上一版 `v0.48`**:(**WS 下行帧的角色门(design/11 §7.1)**:hub 的下行现在是**按帧类型分档**的——只有 7 个**状态机信号**(`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full` / `session_read`,**均不含任何内容**)继续发给**所有已认证连接**(含 `viewer`),**其余全部下行帧**(流式内容 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` / `stream_chunk` / `stream_end` / `thinking`;持久化 `message_persisted` / `message_edited` / `message_deleted` / `channel_persisted`;系统内部 `graph_update` / `subagent_event` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`;私有状态 `mood` / `device_update` / `reminder`)**改为仅发 `owner` + `admin`**)。**对 `viewer` 是收紧**:此前它连上 `/ws/chat` 就能收到角色回复正文与推理内容,现在收不到;**`owner` / `admin` 行为不变**;**无端点、无字段形状变更**——`viewer` 的 12 条只读监控 REST 端点(`viewerAuth`,v0.40)与 `/ws/chat` 的连接鉴权都**照旧**;**新帧类型默认只发 owner/admin**(fail-closed)。见 §3 开头的「下行帧的角色门」与 v0.48 变更记录。**上一版 `v0.47`**:(**`graph_update` 子代理泳道根节点新增可选 `props.persona`**:派发子代理时若指定了目标角色,该分支根节点的 `props` 就多一个 `persona` 键,标注它**正在扮演的角色**——用途是让流程图能显示「这个子代理是以谁的身份在替班」,而不只是子代理 id;**纯增量**:不带角色派发时该键不出现,老客户端忽略未知键即可,见 §3.5 与 v0.47 变更记录)。**上一版 `v0.46`**:(**`PATCH /api/v1/sessions/{id}` 的 `personaId` 由「可重新绑定该会话的角色」改为「出现即 `400`」**:**会话角色在创建时确定、之后不可变**,要换角色请新开会话(design/22 §8-1)——**明确拒绝**而非静默忽略/200 空操作;**键出现**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)一律 `400 bad_request` 且消息点名 `personaId`,该检查**先于**会话存在性检查(未知会话 + `personaId` 也回 `400` 而不是 `404`)。**破坏性**:按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色 / 显式 `""` 解绑」的客户端必须改为**新建会话**选角色;`title`/`pinned` 行为与 `POST /sessions {personaId}`(创建时绑定)**完全不变**,不再有 `501/200 {personaId}` 的 PATCH 路径)。上一版 `v0.45`:`PUT /admin/channels/{platform}/config` 的 `personaId` 由「省略/`""` 都 = 解绑」修订为三态(**省略(或 `null`)= 保留已存绑定**、**显式 `""` = 解绑**、**非空 id = 绑定**,未知角色仍 `400`)。
## 2. REST 端点(管理,`/api/v1/admin/*`)
@@ -245,10 +245,11 @@
- **握手**: `ws://<host>:8080/ws/chat?token=<JWT>` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。
- **下行(服务端→客户端)**: 经 bus 广播 `{type:"<event>", data:..., turnId?, seq?, sessionId?}`(`turnId`/`seq` 为流式关联字段,见 §3.1;`sessionId` 为**会话持久化(design/16)新增**,能确定会话的事件带上,供客户端按激活会话过滤;**v0.36 起结构化流帧 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` 也带 `sessionId` + `turnId`**——多会话并发流式时客户端据此把 delta 路由到激活会话,见 §3.1)——`message.start/delta/end` / `thinking.start/delta/end` / `tool_call.start/delta/end` / `tool_execution.update`(结构化流,design/04 §1.1),以及向后兼容的 `stream_chunk` / `stream_end` / `thinking`(思维链) / `mood`(情绪变化,见 §3.7) / `reminder` / `device_update` / `tool_used`(工具回显) / `subagent_event` / `graph_update` / **`message_persisted`**(会话持久化增量,见 §3.3)/ **`message_edited`**、**`message_deleted`**(消息编辑/删除增量,见 §3.8)/ **`session_read`**(已读游标推进,见 §3.9)。**会话编排状态(design/15,均为广播,`data.sessionId` 区分会话)**:`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)。语音回复走 `voice_output`(定向回请求连接,非广播)。
- **下行帧的角色门(v0.48,design/11 §7.1)**: 下行帧按**类型**分两类,由后端**唯一一处**收口判定(**不在白名单里的帧默认只发 `owner` + `admin`**,fail-closed):① **状态机信号**(白名单,**不含任何内容**)→ **所有已认证连接**(含 `viewer`):`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(§3.2)、`session_read`(§3.9);② **其余全部** → **仅 `owner` + `admin`**:流式内容 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` / `stream_chunk` / `stream_end` / `thinking`(§3.1)、持久化 `message_persisted`(§3.3)/ `channel_persisted`(§3.4)/ `message_edited` / `message_deleted`(§3.8)、系统内部 `graph_update`(§3.5)/ `subagent_event` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`、私有状态 `mood`(§3.7)/ `device_update` / `reminder`。
- **上行帧与握手回放的角色门(v0.49,design/11 §7.2)**: 与上面那条下行门**成对**,收的是同一个缺口的两处残余——① **客户端→服务端(上行)JSON 帧**:只有 `owner` / `admin` 连接可以发;非 `owner`/`admin`(即 `viewer`)连接发来的**任何**上行 JSON 帧都**默认被拒**,回复是**定向**(只回发送者)的 `{type:"error", data:{code:"forbidden"}}`,**不广播、不驱动模型、不落库、不返回任何内容**。被拒帧:`chat` / `interrupt` / `subagent_ask` / `task_output_query` / `voice_input` / `mode_switch`——**未登记的新上行帧同样默认拒**,要放行必须先在 design/11 §7.2 登记白名单(实现上那把「默认拒」也必须同步改为登记表,不能直接删)。② **握手 `history` 回放**:仅 `owner` + `admin` 连接收到;`viewer` 连接建立后**不收**回放。**协议级帧不受影响**:WS ping/pong 与正常 close 照常(否则连接会掉),被拒的上行帧也不会关掉连接。
> **这是「谁被主动推送」的门,不是数据可见性**:`viewer` 的 12 条只读监控端点(`viewerAuth`,v0.40)**照旧**(含 `GET /personas` 的角色级 `mood`);`viewer` **仍可连** `/ws/chat`(连接鉴权不变),只是收不到第 ② 类帧。定向回请求连接的帧(`voice_output` 等)不受影响。
> **这是「谁被主动推送」的门,不是数据可见性**:`viewer` 的 12 条只读监控端点(`viewerAuth`,v0.40)**照旧**(含 `GET /personas` 的角色级 `mood`);`viewer` **仍可连** `/ws/chat`(连接鉴权不变),只是收不到第 ② 类帧。定向回请求连接的帧(`voice_output` 等)不受影响。**上一条的门只管「推给谁」;「谁能发、谁能拿回放」见上面 v0.49 那条**——`viewer` 连上后**发不出任何指令**、也**收不到 `history` 回放**。
> **`history` 回放事件(design/16,新增)**:WS 握手升级成功后、该连接下一帧即收到 `{type:"history", sessionId:"web", data:{messages:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,personaId?,createdAt}]}}`——回放主会话最新 50 条(`thinking` 已剥离,不泄给非 owner;默认主会话,次级会话由客户端按激活会话走 `GET /sessions/{id}/messages` 拉取)。客户端据此在重连/新开连接后补全上下文。消息体含 `senderName?`(第三方昵称,design/16 §4.6,主人私聊为空)与 `personaId?`(v0.42,说话时的角色,design/22 §7-5,空 = 默认角色)。
> **`history` 回放事件(design/16,新增)**:WS 握手升级成功后、该连接下一帧即收到 `{type:"history", sessionId:"web", data:{messages:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,personaId?,createdAt}]}}`——回放主会话最新 50 条(`thinking` 已剥离;默认主会话,次级会话由客户端按激活会话走 `GET /sessions/{id}/messages` 拉取)。客户端据此在重连/新开连接后补全上下文。消息体含 `senderName?`(第三方昵称,design/16 §4.6,主人私聊为空)与 `personaId?`(v0.42,说话时的角色,design/22 §7-5,空 = 默认角色)。**角色门(v0.49,design/11 §7.2 A)**:这条回放**只发 `owner` + `admin` 连接**;`viewer` 连接建立后**不会**收到 `history` 帧(它若需要只读数据,走 §1 的只读 REST 端点)。
### 3.1 结构化流事件(`message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*`)
- **事件壳**:`{type, sessionId?, turnId?, seq?, data}`, `data` 为**类型化结构**(非 `any`)。`seq` 为 hub 级单调递增序号,供客户端将一次突发的流式事件排序关联。**`sessionId` 为事件所属会话、`turnId` 为该轮助手回合 id(均 v0.36 起带上)**:`sessionId` 由 loop 在回合边界注入(模型适配器在 loop 之下、不感知会话,恒留零值;loop 外无会话上下文的事件该字段为空),`turnId` 由网关每个回合新生成并挂进运行上下文——同一会话同时只有一个 in-flight 回合,故**同轮内所有帧的 `turnId` 一致**。客户端据此把 `message.delta` / `thinking.delta` / `tool_call.*` / `tool_execution.update` 过滤到 `sessionId === activeSessionId` 的会话,忽略非激活会话的帧(多会话并发流式不再串话)。
@@ -337,7 +338,7 @@
### 4.1 一次性接入流程(三步走)
1. **拿令牌**:`POST /api/v1/auth/login` `{username,password}` → `{data:{token,expiresAt,role}}`(JWT,默认 24h,`role` ∈ `owner|admin|viewer`)。
2. **声明版本(可选/advisory)**:可带 `X-API-Version: 1` + `Authorization: Bearer <token>`;WS 握手用 `ws://<host>:<port>/ws/chat?token=<token>`(或 `Authorization: Bearer`)。**`X-API-Version` 是**advisory**惯例头——服务端当前不校验、不据此协商版本,带不带行为不变**;`viewer` 只能读会话/人格元数据;读写会话消息、管理写操作需 `owner`/`admin`。
2. **声明版本(可选/advisory)**:可带 `X-API-Version: 1` + `Authorization: Bearer <token>`;WS 握手用 `ws://<host>:<port>/ws/chat?token=<token>`(或 `Authorization: Bearer`)。**`X-API-Version` 是**advisory**惯例头——服务端当前不校验、不据此协商版本,带不带行为不变**;`viewer` 只能读会话/人格元数据;读写会话消息、管理写操作需 `owner`/`admin`(**WS 上 `viewer` 也只收 7 个状态机信号帧:既收不到 `history` 回放,也发不出任何上行指令**,见 §3 顶部两把角色门,v0.48 + v0.49)。
3. **带端标识**(可选但推荐):每条消息/请求可带 `deviceId`(见 §4.3),让她知道你从哪个端来。
### 4.2 会话与消息用法(聊天)
@@ -377,7 +378,8 @@
- 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。
## 5. 变更记录
- `v0.48`(**WS 下行帧的角色门**:`/ws/chat` 的下行帧改为**按帧类型分档**——**对 `viewer` 是收紧**(此前它连上就能收到角色回复正文与推理内容,现在收不到),**`owner` / `admin` 行为不变**,**无端点、无字段形状变更**;依据 design/11 §7.1,主人 2026-10-03 拍板「按建议收紧」):① **问题**:hub 的下行是**全连接广播**,而 `/ws/chat` 只验 JWT(任意角色可连),于是 `viewer` 连接会收到 `message.*` / `thinking.*` / `tool_call.*`(**含角色回复正文与推理内容**)、`message_persisted` / `message_edited` / `message_deleted`、`graph_update`、`mood`、`subagent_event` / `task_output` / `device_update` / `reminder` 等——与 design/19 §1/§3「flow 可视化仅 owner/admin」直接冲突,也与 `GET /sessions/{id}/messages` 的 owner/admin 档位不一致(**同一批内容从 WS 旁路漏出**)。② **规则(fail-closed,默认拒)**:**只有白名单里的 7 个「状态机信号」**(`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full` / `session_read`,**均不含任何内容**)发给**所有已认证连接**(含 `viewer`);**其余全部**(`stream_chunk` / `stream_end` / `thinking`、`message_persisted` / `message_edited` / `message_deleted` / `channel_persisted`、`graph_update` / `subagent_event` / `subagent.result` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`、`mood` / `device_update` / `reminder`)**只发 `owner` + `admin`**;**未登记的新帧类型默认只发 owner/admin**(要进白名单须先在 design/11 §7.1 登记理由「不含内容」)。③ **实现**:收口为**唯一一处**「按帧类型分类后投递」的入口(`internal/services/gateway/ws_frame_scope.go` + `hub.broadcast`),**不给每个调用点加开关**(那正是「漏一处即泄漏」的写法)——流式 `message.*` / `thinking.*` / `tool_call.*`(`broadcastStream`)、`stream_chunk` / `stream_end`(`runTurn` / `streamChat`)、`message_persisted` / `message_edited` / `message_deleted`、`mood` / `capacity_awaken`、编排状态信号(`StateSink`)、`graph_update` / `subagent_event` / `device_update` / `reminder`(`relay` 归一化)**全部经同一入口**,不存在第二条投递路径。④ **语义澄清(不是数据从此不可见)**:这条门管「**谁该被主动推送**」——`viewer` 的 **12 条只读监控端点**(v0.40 `viewerAuth`,含 `GET /personas` 的角色级 `mood`)**逐条照旧**;`/ws/chat` 的**连接鉴权不变**(`viewer` 仍可连,只是收不到上列帧);**定向回请求连接的帧**(`voice_output` / `error` / `subagent_ask`、`task_output_query` 的应答)不进这道门、行为不变。⑤ **测试与变异验证**:真 WS 连接 + 真角色 token 的**逐帧表驱动**断言(design/11 §7.1 表里**每一个**帧类型 × 三种角色:`owner` / `admin` 两类都收得到,`viewer` 只收白名单)+ 帧类型 × 角色的直接矩阵 + 「白名单精确等于 7 个状态信号」与「未登记类型默认拒」的锁定;变异验证:把 `graph_update` 改回全广播 → 「viewer 收不到 `graph_update`」必须 FAIL(已跑,见提交说明),把 `session_idle` 改走受限路径 → 「viewer 收得到 `session_idle`」必须 FAIL(已跑)。⑥ **已知残留(本次未动,留待主人裁决)**:`/ws/chat` 的**上行**仍未按角色授权(`viewer` 仍可发 `chat` / `interrupt` / `subagent_ask` / `task_output_query` / `voice_input`,其应答是**定向**帧、不经过本门),且握手时的 `history` 回放仍对任意角色照发(`thinking` 已剥离)——二者都在 design/11 §7.1 本次门的作用域之外。
- `v0.49`(**WS 握手回放 + 上行帧的角色门**:把 design/11 §7.1 下行门落地时查出的**同一类缺口的另两处**一并收口——**对 `viewer` 继续收紧**,**`owner` / `admin` 行为不变**,**无端点、无字段形状变更**;依据 design/11 §7.2,2026-10-03 落实):① **问题**:本质与 §7.1 同一个——`viewer` 是**只读监控档**,却能通过 WS 拿到内容、甚至发起写入。(a) 握手 `history` 回放**对任意角色照发**(回放主会话最近 50 条**消息正文**,`thinking` 已剥离但正文本身即主人的对话),`viewer` 一连上就把对话拿走了;(b) `route` 的**上行分发完全没有角色检查**,`viewer` 发 `chat` 能**真的驱动模型并把消息落库**(只读档拿到写权限),`subagent_ask` / `task_output_query` 的应答是**定向帧**(`enqueueJSON`)因此**绕过 §7.1 的下行门**,把子代理上下文 / 任务输出直接回给 `viewer`。② **规则(fail-closed,默认拒)**:**A** 握手 `history` 回放**仅 `owner` + `admin`**(`ownerAdminClaims`,与主动推送同一个判据);**B** 非 `owner`/`admin` 连接发来的**任何**上行 JSON 帧一律拒绝——**定向**(只回发送者)回 `{type:"error",data:{code:"forbidden"}}`,**不广播、不驱动模型、不落库、不返回任何内容**;被拒帧:`chat` / `interrupt` / `subagent_ask` / `task_output_query` / `voice_input` / `mode_switch`(**默认拒**,未登记的一律拒)。**协议级帧不受影响**:WS ping/pong 与正常 close 照常(否则连接会掉),被拒帧也不关连接。③ **实现**:A 收口在**唯一**发出回放的 `hub.replayHistory` 顶部(**不放调用点**,没有第二个调用者能绕过);B 收口在 `wsConn.route` 的**第一条语句**,且写成「**默认拒**」而**不是被拒类型黑名单**——新加的上行帧类型自动被拒,漏一处也不会放行。④ **合口径(与 §7.1 一起读)**:`viewer` = **可以连接、可以读 12 条只读监控 REST(v0.40)、可以收 7 个状态机信号帧(v0.48)**;**除此以外 WS 上拿不到任何内容,也发不出任何指令**。⑤ **未来放宽的要求**:将来若真需要 `viewer` 上行(如手机端以 viewer 凭据做只读订阅之外的交互),**必须先在 design/11 §7.2 给它开口并登记白名单**;代码里那把「默认拒」必须**改造成登记表**,**不得直接删掉**。⑥ **测试与变异验证**:真 WS 连接 + 真 JWT(复用 v0.48 的测试装置)。A:`viewer` 连接**收不到** `history` 回放,`owner` / `admin` **收得到**(且回放里 `thinking` 仍为空);B:`viewer` 依次发 `chat` / `interrupt` / `subagent_ask` / `task_output_query` / `voice_input` / `mode_switch`,**每次都收到定向 `forbidden` error**,且其后**无任何内容帧**(对 `subagent_ask` / `task_output_query` 尤其断言「不得收到定向的 `subagent_event` / `task_output`」),全程**计数 runner = 0 且落库 = 0**;另一条 `viewer` 连接作为观察者**不收**这些 error(证明是**定向**而非广播);回归:`owner` 发同一个 `chat` 仍然**真的驱动 runner(= 1)**并把 user + assistant 两条**落库**;协议级:被拒后 ping/pong 往返与正常 close(服务端注销连接)**均正常**。**变异验证**:① 去掉 A 的门 → `viewer` 立刻收到 `history`(A 断言 FAIL,已跑);② 去掉 B 的门 → `viewer` 的 `chat` 驱动 runner **1 次**并**落库 2 条**、`subagent_ask` 直接回 `subagent_event`(B 断言 FAIL,已跑)。⑦ **影响面**:`owner` / `admin` **逐字不变**;`/ws/chat` **连接鉴权不变**(`viewer` 仍可连);无新端点、无字段形状变更;今天**没有 `viewer` 客户端在跑**(`/auth/login` 仍写死签发 `RoleOwner`,除测试外无 viewer 凭据),故线上实际影响为 0。
- `v0.48`(**WS 下行帧的角色门**:`/ws/chat` 的下行帧改为**按帧类型分档**——**对 `viewer` 是收紧**(此前它连上就能收到角色回复正文与推理内容,现在收不到),**`owner` / `admin` 行为不变**,**无端点、无字段形状变更**;依据 design/11 §7.1,主人 2026-10-03 拍板「按建议收紧」):① **问题**:hub 的下行是**全连接广播**,而 `/ws/chat` 只验 JWT(任意角色可连),于是 `viewer` 连接会收到 `message.*` / `thinking.*` / `tool_call.*`(**含角色回复正文与推理内容**)、`message_persisted` / `message_edited` / `message_deleted`、`graph_update`、`mood`、`subagent_event` / `task_output` / `device_update` / `reminder` 等——与 design/19 §1/§3「flow 可视化仅 owner/admin」直接冲突,也与 `GET /sessions/{id}/messages` 的 owner/admin 档位不一致(**同一批内容从 WS 旁路漏出**)。② **规则(fail-closed,默认拒)**:**只有白名单里的 7 个「状态机信号」**(`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full` / `session_read`,**均不含任何内容**)发给**所有已认证连接**(含 `viewer`);**其余全部**(`stream_chunk` / `stream_end` / `thinking`、`message_persisted` / `message_edited` / `message_deleted` / `channel_persisted`、`graph_update` / `subagent_event` / `subagent.result` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`、`mood` / `device_update` / `reminder`)**只发 `owner` + `admin`**;**未登记的新帧类型默认只发 owner/admin**(要进白名单须先在 design/11 §7.1 登记理由「不含内容」)。③ **实现**:收口为**唯一一处**「按帧类型分类后投递」的入口(`internal/services/gateway/ws_frame_scope.go` + `hub.broadcast`),**不给每个调用点加开关**(那正是「漏一处即泄漏」的写法)——流式 `message.*` / `thinking.*` / `tool_call.*`(`broadcastStream`)、`stream_chunk` / `stream_end`(`runTurn` / `streamChat`)、`message_persisted` / `message_edited` / `message_deleted`、`mood` / `capacity_awaken`、编排状态信号(`StateSink`)、`graph_update` / `subagent_event` / `device_update` / `reminder`(`relay` 归一化)**全部经同一入口**,不存在第二条投递路径。④ **语义澄清(不是数据从此不可见)**:这条门管「**谁该被主动推送**」——`viewer` 的 **12 条只读监控端点**(v0.40 `viewerAuth`,含 `GET /personas` 的角色级 `mood`)**逐条照旧**;`/ws/chat` 的**连接鉴权不变**(`viewer` 仍可连,只是收不到上列帧);**定向回请求连接的帧**(`voice_output` / `error` / `subagent_ask`、`task_output_query` 的应答)不进这道门、行为不变。⑤ **测试与变异验证**:真 WS 连接 + 真角色 token 的**逐帧表驱动**断言(design/11 §7.1 表里**每一个**帧类型 × 三种角色:`owner` / `admin` 两类都收得到,`viewer` 只收白名单)+ 帧类型 × 角色的直接矩阵 + 「白名单精确等于 7 个状态信号」与「未登记类型默认拒」的锁定;变异验证:把 `graph_update` 改回全广播 → 「viewer 收不到 `graph_update`」必须 FAIL(已跑,见提交说明),把 `session_idle` 改走受限路径 → 「viewer 收得到 `session_idle`」必须 FAIL(已跑)。⑥ **已知残留(本次未动,留待主人裁决)**:`/ws/chat` 的**上行**仍未按角色授权(`viewer` 仍可发 `chat` / `interrupt` / `subagent_ask` / `task_output_query` / `voice_input`,其应答是**定向**帧、不经过本门),且握手时的 `history` 回放仍对任意角色照发(`thinking` 已剥离)——二者都在 design/11 §7.1 本次门的作用域之外。**(本残留已由 `v0.49` 收口:上行帧默认拒、握手回放仅 `owner`/`admin`,见上一条变更记录与 §3 顶部第二把角色门。)**
- `v0.47`(**`graph_update` 子代理泳道根节点新增可选 `props.persona`**,对客户端**纯增量**;实现依据 design/22 §8-2「跨角色派发 = 把其他角色以子代理形式派出去」):① **`subagent_spawn` 指定目标角色派发时**,该子代理在父会话图里的**分支根节点** `props` 多一个 `persona` 键(值 = 目标角色 id),供流程图显示"这个子代理是以谁的身份在替班",而不只是子代理 id。② **不指定角色派发时该键不出现**:帧形状与 v0.46 逐字一致,老客户端忽略未知键即可。③ **无新端点、无字段改名、无鉴权变更**;`subagent_event` 帧**未新增字段**(其 `role` 会等于目标角色——既有字段的取值语义变化,非形状变更)。④ **`subagent_spawn` 的 `persona` 参数不在本文档契约内**:那是给**模型**用的工具 schema(工具层以「进程能否按该角色运行」为判据,未知角色整批拒绝且不产生任何子代理),不是给客户端的 HTTP/WS 契约。⑤ **验证**:进程内 e2e 断言「子代理实际收到的 system prompt = 目标角色 instructions」+「其记忆写入落在**目标角色**域」+「未知角色整批拒绝」+「**省略 `persona` 时结果 JSON 键集与旧版完全相同**」;变异验证(把目标角色换回父/默认角色、关掉角色校验)对应断言均 FAIL。
- `v0.46`(**会话角色不可变:`PATCH /sessions/{id}` 拒绝 `personaId`**,对客户端**是破坏性变更**——按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色」的客户端必须改为**新建会话**选角;design/22 §8-1 主人拍板定稿):① **`PATCH /api/v1/sessions/{id}` 的 `personaId` 改为「**出现即 `400`**」**:只要请求体里**有 `personaId` 键**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)就回 `400 bad_request`,**错误消息点名 `personaId`** 并说明「会话角色在创建时确定、之后不可变,要换角色请新开会话」。**实现取「明确拒绝」而非静默忽略或 200 空操作**:静默忽略会让客户端以为绑定已改。② **顺序保证**:该检查在解析完 body 后**立即**执行、**先于**会话存在性检查 —— **未知会话 + `personaId` 回 `400`(不是 `404`)**;`title` + `personaId` 同传也整体 `400`,**其余字段一个都不落库**。③ **不变的部分**:`title` / `pinned` 的语义、边界(≤80 rune 等)与响应形状**逐字不变**;`POST /api/v1/sessions {personaId}`(**创建时**绑定、未知角色 `400`、非空回显)**完全不变**;`GET /sessions` 与消息 DTO 的只读 `personaId` 不变。④ **移除的路径**:PATCH 上再也没有 `{personaId}` 成功响应、也没有「不支持角色绑定的 HistoryStore → `501`」这条路(网关的 `Server.setSessionPersona` 及其中间层随之删除;`history.Store.SetSessionPersona` 作为存储能力保留)。⑤ **前端同步**:用户端**移除**「会话行 → 切换角色」菜单与面板「挂角色」下拉,**保留**「新建会话时选角色」;管理面板「角色」列改为**只读**展示创建时绑定的角色。⑥ **测试与变异验证**:新增断言覆盖「键出现即 400(六种取值)+ 绑定未变 + 未知会话仍 400 + `title`/`pinned` 不变」,并把「静默忽略」与「改回写绑定」两种反向实现各跑一次(两处断言均必须 FAIL,见提交说明)。
- `v0.45`(**渠道 `personaId` 三态修订:修「省略 = 解绑」会静默清空绑定**,对客户端**无字段/端点形状变更**,但**语义收紧**——按 v0.44 旧文实现的客户端若靠「省略 `personaId`」来解绑,必须改为**显式传 `""`**;design/22 §2 方案 b):① **`PUT /api/v1/admin/channels/{platform}/config` 的 `personaId` 改为三态**:**键省略(或 JSON `null`)= 保留该账号已存绑定**(不动);**显式 `""` = 解绑**(写盘时该账号不带 `personaId` 键);**非空 id = 绑定该角色**;**未知角色 id 仍 `400 bad_request`**(且**只校验本次真的传了值**的情形——省略时继承的已存绑定在当初写入时已校验)。② **修的问题(数据安全)**:管理面板只在主人**动过**角色下拉时才发这个键(前端 `channelPersona.ts` / `channelPersonaRequest`),而 v0.44 后端把「键不存在」也当成解绑 ——**主人打开一个已绑角色的账号、不动下拉、只改 token 并保存 → 该账号的角色绑定被静默清空**(无提示、无审计可见的理由),两端语义相反。现在后端的「省略」与前端一致 = 保留。③ **实现**:请求 DTO 的该字段由 `string` 变 `*string`(指针区分「省略」与「显式空串」),应用逻辑在既有「读当前配置(channels.json)→ `UpsertChannel` → 落盘 → `bridge.Reload(platform)`」路径内按三态取值(省略时从**同平台同 id** 的已存账号继承,故保存一个账号不会动到同平台其它账号的绑定);**无新端点、无新字段、无响应形状变更**。④ **`GET /api/v1/admin/channels` 每账号的 `personaId` 回显不变**(v0.44 新增,非敏感、不掩码),面板据此回填角色下拉。⑤ **测试**:省略 → 绑定保留、显式 `""` → 清除、非空 id → 绑定、未知 id → `400`(含「省略时改其它字段后读回确认」与「同平台多账号互不影响」),并把「省略 = 解绑」作为变异反向验证(该断言必须失败)。
@@ -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)
}