From c57af9b0dd95428201d59adf17367b8f651f73f0 Mon Sep 17 00:00:00 2001 From: Zyy Date: Sun, 1 Nov 2026 14:21:01 +0800 Subject: [PATCH] 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 --- .../openai/src/transport/correlation.rs | 4 + .../tests/provider/contract/correlation.rs | 18 +++- .../openai/tests/provider/contract/privacy.rs | 84 +++++++++++++++---- docs/api.md | 8 +- 4 files changed, 91 insertions(+), 23 deletions(-) diff --git a/backend/crates/providers/openai/src/transport/correlation.rs b/backend/crates/providers/openai/src/transport/correlation.rs index 44b216317..64c06d51a 100644 --- a/backend/crates/providers/openai/src/transport/correlation.rs +++ b/backend/crates/providers/openai/src/transport/correlation.rs @@ -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] = &[ diff --git a/backend/crates/providers/openai/tests/provider/contract/correlation.rs b/backend/crates/providers/openai/tests/provider/contract/correlation.rs index 58889c1e5..568dc2049 100644 --- a/backend/crates/providers/openai/tests/provider/contract/correlation.rs +++ b/backend/crates/providers/openai/tests/provider/contract/correlation.rs @@ -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"); diff --git a/backend/crates/providers/openai/tests/provider/contract/privacy.rs b/backend/crates/providers/openai/tests/provider/contract/privacy.rs index 8ee548f70..1a385b421 100644 --- a/backend/crates/providers/openai/tests/provider/contract/privacy.rs +++ b/backend/crates/providers/openai/tests/provider/contract/privacy.rs @@ -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::(&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( diff --git a/docs/api.md b/docs/api.md index 4980e928e..78c9714bc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 包裹结构