fix(openai): strip sentry-trace and baggage from outbound requests

Remove tracing headers and their WebSocket metadata copies through the existing correlation cleanup. Cover cache identity stability and WebSocket reuse after privacy header changes.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-11-01 14:21:01 +08:00
co-authored by Codex
parent 641f43b303
commit c57af9b0dd
4 changed files with 91 additions and 23 deletions
@@ -26,8 +26,12 @@ const METADATA_FIELDS: &[&str] = &["turnMetadata", "turn_metadata", "x-codex-tur
const TRACE_FIELDS: &[&str] = &[
"traceparent",
"tracestate",
"sentry-trace",
"baggage",
"ws_request_header_traceparent",
"ws_request_header_tracestate",
"ws_request_header_sentry-trace",
"ws_request_header_baggage",
];
// Live 的 x-session-id 与 voice-session-id 是业务会话引用,必须原样传递
const ID_HEADERS: &[&str] = &[
@@ -6,7 +6,8 @@ fn metadata() -> Value {
json!({"session_id":"source-session", "thread_id":"source-thread", "turn_id":"source-turn",
"parent_thread_id":"source-session", "forked_from_thread_id":"source-session",
"parent_turn_id":"source-turn", "root_turn_id":"source-turn",
"window_id":"source-window", "context_window_id":"source-window", "future":{"keep":true}})
"window_id":"source-window", "context_window_id":"source-window", "future":{"keep":true},
"sentry-trace":"source-trace", "baggage":"sentry-environment=synthetic"})
}
fn payload(websocket: bool) -> ProtocolPayload {
@@ -43,7 +44,9 @@ fn payload(websocket: bool) -> ProtocolPayload {
"x-codex-window-id":"source-window", "x-codex-parent-thread-id":"source-session",
"x-codex-turn-metadata":full.to_string(),
"ws_request_header_traceparent":"00-11111111111111111111111111111111-2222222222222222-01",
"ws_request_header_tracestate":"vendor=source"
"ws_request_header_tracestate":"vendor=source",
"ws_request_header_sentry-trace":"source-trace",
"ws_request_header_baggage":"sentry-environment=synthetic"
}
}).as_object().unwrap().clone()).unwrap().with_context(Map::from_iter([
("use_websocket".into(), json!(websocket)),
@@ -53,7 +56,10 @@ fn payload(websocket: bool) -> ProtocolPayload {
("opaque_request_headers".into(), json!([
["x-client-turn-id",STANDARD.encode("source-turn")],
["traceparent",STANDARD.encode("00-11111111111111111111111111111111-2222222222222222-01")],
["tracestate",STANDARD.encode("vendor=source")]
["tracestate",STANDARD.encode("vendor=source")],
["Sentry-Trace",STANDARD.encode("source-trace")],
["baggage",STANDARD.encode("sentry-environment=synthetic")],
["baggage",STANDARD.encode("sentry-release=synthetic")]
])),
]))
}
@@ -132,9 +138,13 @@ async fn account_correlation_preserves_turns_across_transports_and_account_switc
let (headers, body) = capture.await;
assert!(!headers.contains_key("traceparent"));
assert!(!headers.contains_key("tracestate"));
assert!(!headers.contains_key("sentry-trace"));
assert!(!headers.contains_key("baggage"));
let flat = &body["client_metadata"];
assert!(flat.get("ws_request_header_traceparent").is_none());
assert!(flat.get("ws_request_header_tracestate").is_none());
assert!(flat.get("ws_request_header_sentry-trace").is_none());
assert!(flat.get("ws_request_header_baggage").is_none());
let header: Value =
serde_json::from_str(headers["x-codex-turn-metadata"].to_str().unwrap()).unwrap();
let full: Value =
@@ -153,6 +163,8 @@ async fn account_correlation_preserves_turns_across_transports_and_account_switc
assert_eq!(flat["x-client-turn-id"], "source-turn");
assert_eq!(flat["turn_id"], "source-turn");
for metadata in [&header, &full] {
assert!(metadata.get("sentry-trace").is_none());
assert!(metadata.get("baggage").is_none());
assert_eq!(metadata["turn_id"], "source-turn");
assert_eq!(metadata["parent_turn_id"], "source-turn");
assert_eq!(metadata["root_turn_id"], "source-turn");
@@ -282,16 +282,18 @@ async fn privacy_rewrites_websocket_handshake_and_frame_once() {
}
#[tokio::test]
async fn privacy_header_change_uses_a_new_websocket_pool_connection() {
async fn privacy_header_change_isolates_websocket_then_reuses_stable_headers() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let captured = tokio::spawn(async move {
let mut sockets = Vec::new();
for expected in [
"Bearer first-synthetic-token",
"Bearer second-synthetic-token",
let mut cache_keys = Vec::new();
let mut session_headers = Vec::new();
for (expected, requests) in [
("Bearer first-synthetic-token", 1),
("Bearer second-synthetic-token", 2),
] {
let (stream, _) = timeout(Duration::from_secs(5), listener.accept())
.await
@@ -300,35 +302,81 @@ async fn privacy_header_change_uses_a_new_websocket_pool_connection() {
let mut socket =
crate::transport::accept_codex_test_websocket_with(stream, |request, response| {
assert_eq!(request.headers()["authorization"], expected);
assert!(!request.headers().contains_key("sentry-trace"));
assert!(!request.headers().contains_key("baggage"));
session_headers.push(request.headers()["session-id"].clone());
response.headers_mut().insert(
"sec-websocket-extensions",
"permessage-deflate".parse().unwrap(),
);
})
.await;
let frame = socket.next().await.unwrap().unwrap().into_text().unwrap();
assert_eq!(
serde_json::from_str::<Value>(&frame).unwrap()["input"][0]["content"][0]["text"],
"hellohello"
);
socket.send(Message::Text(json!({"type":"response.completed","response":{"id":"resp_privacy_pool","model":"gpt-5.4","status":"completed","output":[]}}).to_string().into())).await.unwrap();
for _ in 0..requests {
let frame = timeout(Duration::from_secs(5), socket.next())
.await
.unwrap()
.unwrap()
.unwrap()
.into_text()
.unwrap();
let body: Value = serde_json::from_str(&frame).unwrap();
assert_eq!(body["input"][0]["content"][0]["text"], "hellohello");
cache_keys.push(body["prompt_cache_key"].as_str().unwrap().to_owned());
socket.send(Message::Text(json!({"type":"response.completed","response":{"id":format!("resp_privacy_pool_{}",cache_keys.len()),"model":"gpt-5.4","status":"completed","output":[]}}).to_string().into())).await.unwrap();
}
// 保留第一条连接,确保下一次握手是池隔离的结果而非断线重连
sockets.push(socket);
}
assert_eq!(session_headers[0], session_headers[1]);
assert_eq!(cache_keys.len(), 3);
assert!(cache_keys.iter().all(|key| key == &cache_keys[0]));
sockets
});
let provider = provider_with_base_url(&store, format!("http://{address}"));
for token in [
"Bearer first-synthetic-token",
"Bearer second-synthetic-token",
for (token, trace) in [
("Bearer first-synthetic-token", Some("first-trace")),
("Bearer second-synthetic-token", Some("second-trace")),
("Bearer second-synthetic-token", None),
] {
let mut config = outbound_policy(true);
config.rules[0].steps[0].value = json!(token);
let operation = Operation::Generate(generate_with_session_context(
"privacy-pool",
Some("privacy-thread"),
None,
));
let original = generate_with_session_context("privacy-pool", Some("privacy-thread"), None);
let mut body = original.protocol_payload().body().clone();
body.insert("prompt_cache_key".into(), json!("privacy-pool-cache"));
let mut context = original.protocol_payload().context().clone();
if let Some(trace) = trace {
context.insert(
"opaque_request_headers".into(),
json!([
["sentry-trace", STANDARD.encode(trace)],
[
"baggage",
STANDARD.encode(format!("sentry-trace_id={trace}"))
]
]),
);
}
let operation = Operation::Generate(
GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object("openai", body)
.unwrap()
.with_context(context),
)
.with_provider_session_state(
ProviderSessionState::new(
"openai",
json!({
"account_id":"acct_provider_contract",
"conversation_id":"privacy-pool",
"continuation_scope":"persisted"
})
.as_object()
.unwrap()
.clone(),
)
.unwrap(),
),
);
let mut stream = provider
.clone()
.execute(
+6 -2
View File
@@ -409,8 +409,8 @@ Responses 也不透传 `x-stainless-*`、`Origin`、`Referer`、`sec-ch-ua*` 和
两者同时存在时仍优先使用 `session-id`。正文中的 `client_metadata.session_id`、
`prompt_cache_key` 不受这条请求头规则影响。
`thread-id`、turn metadata 等 Codex 协议字段及未知业务扩展不受下游环境头过滤规则影响;
出站移除下游的 `traceparent`、`tracestate` 及 WS metadata 中对应的
`ws_request_header_traceparent`、`ws_request_header_tracestate`
出站移除下游的 `traceparent`、`tracestate`、`sentry-trace`、`baggage`,
以及 WS metadata 中对应的 `ws_request_header_*` 副本
会话、线程、窗口及父子线程引用在出站时按本次安装身份稳定映射为 UUID;
默认使用账号安装身份,同账号保留引用关系,换账号使用不同标识。HTTP 请求头、正文 metadata、WS 副本及
@@ -1887,6 +1887,10 @@ JSON 路径支持 `$`、`.成员`、`["带特殊字符的键"]`、`[索引]`、`
删除动作的 `pattern: null` 表示无条件删除,字符串模式表示只删除字符串值匹配的字段或数组项。
请求头规则生成的值必须是 ASCII 文本,以保持 HTTP 与 WebSocket opening 的一致性;metadata 范围会自动转义 Unicode
WebSocket 请求头在握手时发送。隐私规则改变握手头时,普通请求按对应连接身份选择连接,
处理结果稳定时可继续复用,不因每次执行删除动作而重连。
依赖原连接的精确续接保留原连接,规则变更不能撤回已有握手发送的值
例如,`turn_metadata` 范围的 `$.workspaces` 配合 `remove_field` 删除整个工作区映射;
`$.workspaces.*.associated_remote_urls.*` 配合 `remove_field` 和 `pattern: "private-org"` 只删除命中的远端;
`$.workspaces` 配合 `rename_key` 可改写工作区路径键名。环境文本替换必须保留 XML 包裹结构