fix(ws): restore redacted PII in provider frames before client delivery

Responses WebSocket 只实现了脱敏的一半:请求侧 mask 之后,provider 事件帧在推给
客户端之前没有还原,于是 session 映射内的占位符以 <AETHER:EMAIL:...> 的形式直接
透给客户端。这里补齐响应侧,语义与 HTTP 路径对齐。

- 还原点是 relay loop 的最后一跳(send_client_message 之前、capture_client_frame
  之前),对应 HTTP 的 restore_sync_response_body / StreamingResponseRestorer 所在
  位置。审计与终态观测继续消费脱敏态事件,只有发往客户端的那一份拷贝被还原。
- 复用 privacy::restore_json_strings(改为 pub(crate))与
  RedactionSession::restore_text,不复制任何还原逻辑:只还原本 session mask 过的
  映射,未映射的占位符原样保留;type / model / id 等协议字段不可能命中 sentinel,
  因此不受影响。批量 {"chunks":[...]} 帧一并递归还原。
- session 生命周期:mask 仍然是 per-turn(slot 依旧每轮新建),但 session 改由连接
  持有,按有界 FIFO 留最近 8 轮。理由是 WS 的会话历史留在上游,continuation 只发
  增量输入,per-turn 释放会漏还原后续响应里回显的更早轮次占位符;HTTP 不会漏,是
  因为它每次重发整段历史、重新 mask 会派生出同一个 sentinel。被挤出窗口的轮次退回
  「占位符原样透传」,不会错误还原成别的值。
- 未命中还原时不改写字节;连接上没有任何 mask session 时(未启用脱敏)连事件 clone
  都不做。

测试:redaction.rs 新增 8 条单测(还原命中/批量帧/未映射占位符原样/未命中不改写/
无 session 不介入/空 session 不留存/审计侧入参不被改写/跨轮还原/窗口有界);
responses_websocket_e2e 新增一条用例,mock 上游回显收到的 input,断言上游只看到
占位符而客户端拿到真实邮箱。
This commit is contained in:
AAEE86
2026-08-17 14:53:46 +08:00
committed by ZheFox
parent dbf2809bd6
commit 6fc02dad3e
8 changed files with 603 additions and 27 deletions
@@ -9,6 +9,7 @@ use tokio::task::JoinHandle;
use super::adapter::{ResponsesWebSocketDrainDirective, ResponsesWebSocketProtocolAdapter};
use super::binding::UpstreamBindingIdentity;
use super::redaction::ResponsesWebSocketRedactionRestorer;
use super::turn_state::ResponsesTurnState;
use crate::ai_serving::{AiExecutionDecision, ResponsesWebSocketBodyNormalization};
@@ -28,6 +29,12 @@ pub(super) struct BoundResponsesConnection {
pub(super) binding_identity: UpstreamBindingIdentity,
/// 这条连接上「有没有正在进行的 logical turn」的唯一事实来源。
pub(super) turn_state: ResponsesTurnState,
/// 这条连接迄今 mask 出来的映射,用于把 provider 事件里的占位符换回真实值。
///
/// 刻意按连接持有而不是按 turn 持有:WS 的会话历史留在上游,continuation 只发
/// 增量输入,所以后面几轮的响应可能回显更早那几轮的占位符(理由详见
/// [`super::redaction`])。上游重绑时不重置。
pub(super) redaction_restorer: ResponsesWebSocketRedactionRestorer,
pub(super) next_turn_index: u64,
pub(super) upstream_response_headers: BTreeMap<String, String>,
pub(super) pending_adapter_drain: Option<ResponsesWebSocketDrainDirective>,