From 5eaca14a642fad5d26b85741c0cc528a27c390bb Mon Sep 17 00:00:00 2001 From: Zyy Date: Wed, 4 Nov 2026 15:27:46 +0800 Subject: [PATCH] fix(openai): clear client headers when switching accounts Co-authored-by: Codex --- .../providers/openai/src/transport/request.rs | 51 ++++++ .../provider/contract/account_isolation.rs | 150 +++++++++++++----- docs/api.md | 7 +- docs/architecture.md | 3 +- 4 files changed, 163 insertions(+), 48 deletions(-) diff --git a/backend/crates/providers/openai/src/transport/request.rs b/backend/crates/providers/openai/src/transport/request.rs index 8dc8dc993..7cd14572e 100644 --- a/backend/crates/providers/openai/src/transport/request.rs +++ b/backend/crates/providers/openai/src/transport/request.rs @@ -68,6 +68,14 @@ const ACCOUNT_BOUND_STATE_KEYS: &[&str] = &[ const TURN_METADATA_KEYS: &[&str] = &["turnMetadata", "turn_metadata", "x-codex-turn-metadata"]; +const CROSS_ACCOUNT_CLIENT_HEADERS: &[&str] = &[ + "traceparent", + "tracestate", + "sentry-trace", + "baggage", + "x-openai-fedramp", +]; + const INSTALLATION_ID_KEYS: &[&str] = &[ "installation_id", "installationId", @@ -471,6 +479,16 @@ pub(crate) fn scope_request_to_account( account_scope: RequestAccountScope, verified_turn_state: Option<&str>, ) { + if account_scope == RequestAccountScope::Different { + // 明确换号时同步清理各 wire 副本,避免把原账号的追踪与合规上下文带给新账号 + for name in CROSS_ACCOUNT_CLIENT_HEADERS { + request.passthrough_headers.remove(*name); + } + clear_cross_account_client_metadata(request.body_mut()); + if let Some(raw) = &mut request.turn_metadata { + clear_cross_account_turn_metadata(raw); + } + } let reset_account_state = !account_scope.can_reuse_account_state(); let client_metadata_turn_state = metadata_string(request, "x-codex-turn-state"); let turn_state = if reset_account_state { @@ -584,6 +602,39 @@ pub(crate) fn scope_request_to_account( request.turn_metadata = turn_metadata; } +fn clear_cross_account_client_metadata(metadata: &mut Map) -> bool { + let original_len = metadata.len(); + metadata.retain(|key, _| { + let name = key.strip_prefix("ws_request_header_").unwrap_or(key); + !CROSS_ACCOUNT_CLIENT_HEADERS.contains(&name) + }); + let mut changed = metadata.len() != original_len; + // 只处理协议 metadata 容器,业务输入和工具结果保持原始内容 + for key in TURN_METADATA_KEYS + .iter() + .copied() + .chain(["client_metadata"]) + { + match metadata.get_mut(key) { + Some(Value::Object(value)) => changed |= clear_cross_account_client_metadata(value), + Some(Value::String(raw)) => changed |= clear_cross_account_turn_metadata(raw), + _ => {} + } + } + changed +} + +fn clear_cross_account_turn_metadata(raw: &mut String) -> bool { + if let Ok(Value::Object(mut metadata)) = serde_json::from_str(raw) + && clear_cross_account_client_metadata(&mut metadata) + && let Some(encoded) = serialize_ascii_turn_metadata(&Value::Object(metadata)) + { + *raw = encoded; + return true; + } + false +} + fn metadata_string(request: &CodexResponsesRequest, key: &str) -> Option { request .client_metadata()? diff --git a/backend/crates/providers/openai/tests/provider/contract/account_isolation.rs b/backend/crates/providers/openai/tests/provider/contract/account_isolation.rs index 668011983..fd2e71569 100644 --- a/backend/crates/providers/openai/tests/provider/contract/account_isolation.rs +++ b/backend/crates/providers/openai/tests/provider/contract/account_isolation.rs @@ -3,6 +3,25 @@ use super::*; const SELECTED_ACCOUNT: &str = "acct_scope_same"; +const CLIENT_ACCOUNT_HEADERS: &[(&str, &[&str])] = &[ + ( + "traceparent", + &["00-11111111111111111111111111111111-2222222222222222-01"], + ), + ("tracestate", &["vendor=client"]), + ("sentry-trace", &["client-trace"]), + ("baggage", &["environment=synthetic", "release=synthetic"]), + ("x-openai-fedramp", &["true", "false"]), +]; + +fn client_account_metadata() -> Map { + let mut metadata = Map::new(); + for (name, values) in CLIENT_ACCOUNT_HEADERS { + metadata.insert((*name).to_owned(), json!(values[0])); + metadata.insert(format!("ws_request_header_{name}"), json!(values[0])); + } + metadata +} pub(super) fn isolation_history() -> Value { json!([ @@ -25,38 +44,54 @@ pub(super) fn isolation_history() -> Value { } fn isolation_payload() -> ProtocolPayload { - ProtocolPayload::json_object( - "openai", - json!({ - "model": "gpt-5.4", - "input": isolation_history(), - "client_metadata": { - "parent_response_id": "resp_parent_old", - "guardian_credits_requested": "true", - "x-codex-turn-metadata": r#"{"parent_response_id":"client-correlation"}"#, - "mcp_attribution": r#"{"status":"complete","sources":[]}"# - } - }) - .as_object() + let mut turn_metadata = client_account_metadata(); + turn_metadata.insert("parent_response_id".to_owned(), json!("client-correlation")); + turn_metadata.insert("workspace".to_owned(), json!("/工作区")); + turn_metadata.insert("future".to_owned(), json!({"traceparent":"business-value"})); + let mut body = json!({ + "model": "gpt-5.4", + "input": isolation_history(), + "client_metadata": { + "parent_response_id": "resp_parent_old", + "guardian_credits_requested": "true", + "x-codex-turn-metadata": Value::Object(turn_metadata.clone()).to_string(), + "turnMetadata": turn_metadata, + "mcp_attribution": r#"{"status":"complete","sources":[]}"# + } + }); + body.as_object_mut() .unwrap() - .clone(), - ) - .unwrap() - .with_context(Map::from_iter([( - "opaque_request_headers".to_owned(), + .extend(client_account_metadata()); + body["client_metadata"] + .as_object_mut() + .unwrap() + .extend(client_account_metadata()); + body["turnMetadata"] = json!(turn_metadata); + body["turn_metadata"] = json!(Value::Object(turn_metadata.clone()).to_string()); + let mut headers = vec![ + json!(["X-OpenAI-Account-Routing-Override", STANDARD.encode("us")]), json!([ - ["X-OpenAI-Account-Routing-Override", STANDARD.encode("us")], - [ - "x-openai-account-routing-override", - STANDARD.encode("us_cr") - ], - ["X-OpenAI-Fedramp", STANDARD.encode("true")], - ["x-openai-fedramp", STANDARD.encode("false")], - ["x-codex-guardian", STANDARD.encode("true")], - ["x-business-extension", STANDARD.encode("first")], - ["x-business-extension", STANDARD.encode("second")] + "x-openai-account-routing-override", + STANDARD.encode("us_cr") ]), - )])) + json!(["x-codex-guardian", STANDARD.encode("true")]), + json!(["x-business-extension", STANDARD.encode("first")]), + json!(["x-business-extension", STANDARD.encode("second")]), + ]; + for (name, values) in CLIENT_ACCOUNT_HEADERS { + for value in *values { + headers.push(json!([name.to_ascii_uppercase(), STANDARD.encode(value)])); + } + } + ProtocolPayload::json_object("openai", body.as_object().unwrap().clone()) + .unwrap() + .with_context(Map::from_iter([ + ("opaque_request_headers".to_owned(), json!(headers)), + ( + "turn_metadata".to_owned(), + json!(Value::Object(turn_metadata).to_string()), + ), + ])) } fn isolation_context(owner: Option<&str>) -> AttemptContext { @@ -72,14 +107,24 @@ fn assert_isolated_request( owner: Option<&str>, ) { assert!(!headers.contains_key("x-openai-account-routing-override")); - assert_eq!( - headers - .get_all("x-openai-fedramp") + let switched = owner.is_some_and(|owner| owner != SELECTED_ACCOUNT); + for (name, values) in CLIENT_ACCOUNT_HEADERS { + let actual: Vec<_> = headers + .get_all(*name) .iter() .map(|value| value.to_str().unwrap()) - .collect::>(), - ["true", "false"] - ); + .collect(); + let expected = if switched { &[][..] } else { *values }; + assert_eq!(actual, expected, "{name}, owner={owner:?}"); + for metadata in [body, &body["client_metadata"]] { + let expected = (!switched).then(|| json!(values[0])); + assert_eq!(metadata.get(*name), expected.as_ref()); + assert_eq!( + metadata.get(format!("ws_request_header_{name}")), + expected.as_ref() + ); + } + } assert_eq!(headers["x-codex-guardian"], "true"); assert_eq!( headers @@ -98,14 +143,31 @@ fn assert_isolated_request( body["client_metadata"]["guardian_credits_requested"], "true" ); + let mut expected_turn_metadata = if switched { + Map::new() + } else { + client_account_metadata() + }; + expected_turn_metadata.insert("parent_response_id".to_owned(), json!("client-correlation")); + expected_turn_metadata.insert("workspace".to_owned(), json!("/工作区")); + expected_turn_metadata.insert("future".to_owned(), json!({"traceparent":"business-value"})); + for metadata in [ + &body["turnMetadata"], + &body["turn_metadata"], + &body["client_metadata"]["turnMetadata"], + &body["client_metadata"]["x-codex-turn-metadata"], + ] { + let actual = match metadata { + Value::String(raw) => serde_json::from_str(raw).unwrap(), + value => value.clone(), + }; + assert_eq!(actual, Value::Object(expected_turn_metadata.clone())); + } + let header = headers["x-codex-turn-metadata"].to_str().unwrap(); + assert!(header.is_ascii()); assert_eq!( - serde_json::from_str::( - body["client_metadata"]["x-codex-turn-metadata"] - .as_str() - .unwrap() - ) - .unwrap(), - json!({"parent_response_id": "client-correlation"}), + serde_json::from_str::(header).unwrap(), + Value::Object(expected_turn_metadata) ); assert_eq!( body["client_metadata"]["mcp_attribution"], @@ -115,7 +177,7 @@ fn assert_isolated_request( } #[tokio::test] -async fn http_applies_deployment_routing_and_only_scopes_guardian_parent_reference() { +async fn http_scopes_client_headers_and_guardian_parent_reference_to_the_account() { for owner in [Some(SELECTED_ACCOUNT), Some("acct_scope_old"), None] { let store = Arc::new(MemoryAccountStore::default()); create_account(&store, SELECTED_ACCOUNT).await; @@ -158,7 +220,7 @@ async fn http_applies_deployment_routing_and_only_scopes_guardian_parent_referen } #[tokio::test] -async fn websocket_applies_deployment_routing_and_only_scopes_guardian_parent_reference() { +async fn websocket_scopes_client_headers_and_guardian_parent_reference_to_the_account() { for owner in [Some(SELECTED_ACCOUNT), Some("acct_scope_old"), None] { let store = Arc::new(MemoryAccountStore::default()); create_account(&store, SELECTED_ACCOUNT).await; diff --git a/docs/api.md b/docs/api.md index cb0317efc..8c360daff 100644 --- a/docs/api.md +++ b/docs/api.md @@ -399,7 +399,7 @@ API Key 与 OAuth 共用模拟客户端画像(`User-Agent`、`originator`、`v `x-openai-internal-codex-residency` 和 `x-openai-account-routing-override` 分别由部署配置 `openai.residency`、`openai.account_routing_override` 控制,配置值原样发送并覆盖客户端同名头, 省略或 `null` 时删除对应请求头。配置说明见 [部署与运维](../deploy/README.md#启动后的运行设置) -`x-openai-fedramp` 按业务头透传,保留原始多值。 +`x-openai-fedramp` 默认按业务头透传并保留原始多值,Responses 明确换号时清理。 上述过滤描述原生转发;已信任插件的显式 header 改写可以覆盖账号与画像生成的默认头, 不按认证或会话字段名称拦截,边界见 [SDK 请求中间件](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#模型请求与-attempt) @@ -410,8 +410,9 @@ Responses 也不透传 `x-stainless-*`、`Origin`、`Referer`、`sec-ch-ua*` 和 两者同时存在时仍优先使用 `session-id`。正文中的 `client_metadata.session_id`、 `prompt_cache_key` 不受这条请求头规则影响。 `thread-id`、turn metadata 等 Codex 协议字段及未知业务扩展不受下游环境头过滤规则影响; -`traceparent`、`tracestate`、`sentry-trace`、`baggage` 及 WS metadata 中对应的 -`ws_request_header_*` 副本保持原值 +Responses 明确换号时清理 `traceparent`、`tracestate`、`sentry-trace`、`baggage` 和 +`x-openai-fedramp`,同时清理正文、`client_metadata`、turn metadata 中的同名字段及 +`ws_request_header_*` 副本。同账号及归属未知时保留原值,业务输入和工具结果保持原始内容 会话、线程、窗口及父子线程引用在出站时按本次安装身份稳定映射为 UUID; 默认使用账号安装身份,同账号保留引用关系,换账号使用不同标识。HTTP 请求头、正文 metadata、WS 副本及 diff --git a/docs/architecture.md b/docs/architecture.md index 609eb6bab..783a534f0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -520,7 +520,8 @@ OpenAI 模型目录用于发现与原生 Responses 账号资格筛选,OAuth - OpenAI 是透明边界。正文兼容、身份归一和显式请求策略之外的业务字段保留原始语义; 业务响应按原始字节转发,客户端错误兼容与原生续写额度恢复按明确例外处理,见 [OpenAI 透传](api.md#openai-透传)。 - `transport/correlation` 在出站副本上按本次安装身份统一映射关联标识,客户端 trace 原样保留。 + `transport/correlation` 在出站副本上按本次安装身份统一映射关联标识。 + Responses 的账号归属判断及换号头部清理由 Provider 统一执行,各传输消费同一出站请求。 原生安装身份取自账号;插件的身份生成、持久化及关联字段改写通过既有中间件链路自行完成。 内部选号、亲和、连接池与本地诊断继续读取原始身份;具体字段边界见 [API 协议](api.md#请求头与身份) canonical facts 从同一数据旁路提取,用于路由、恢复判断、观测和计费。