diff --git a/backend/crates/providers/openai/src/transport/request.rs b/backend/crates/providers/openai/src/transport/request.rs index 5af84ca78..544498696 100644 --- a/backend/crates/providers/openai/src/transport/request.rs +++ b/backend/crates/providers/openai/src/transport/request.rs @@ -1,9 +1,12 @@ //! 核心 Generate operation 到 Codex Responses wire request 的严格编码。 +use std::io; + use base64::{Engine as _, engine::general_purpose::STANDARD}; use gateway_core::operation::GenerateRequest; use gateway_protocol::openai::WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY; use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; +use serde::Serialize as _; use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; @@ -447,7 +450,8 @@ pub(crate) fn scope_turn_metadata( changed |= metadata.remove(*key).is_some(); } } - if !cross_account + if raw.is_ascii() + && !cross_account && !changed && !INSTALLATION_ID_KEYS .iter() @@ -460,7 +464,40 @@ pub(crate) fn scope_turn_metadata( metadata.insert((*key).to_owned(), Value::String(installation_id.to_owned())); } } - serde_json::to_string(&metadata).ok() + // Codex 的 turn metadata 同时承载于 HTTP header 与 WS client_metadata。 + // 改写安装 ID 后仍须保持官方 to_ascii_json_string 的编码合同;普通 + // to_string 会把中文工作区路径还原成 UTF-8,触发上游 WS metadata 后 Close 1000。 + let mut bytes = Vec::new(); + metadata + .serialize(&mut serde_json::Serializer::with_formatter( + &mut bytes, + AsciiTurnMetadataFormatter, + )) + .ok()?; + String::from_utf8(bytes).ok() +} + +struct AsciiTurnMetadataFormatter; + +impl serde_json::ser::Formatter for AsciiTurnMetadataFormatter { + fn write_string_fragment( + &mut self, + writer: &mut W, + fragment: &str, + ) -> io::Result<()> { + let mut start = 0; + for (index, ch) in fragment.char_indices() { + if ch.is_ascii() { + continue; + } + writer.write_all(&fragment.as_bytes()[start..index])?; + for unit in ch.encode_utf16(&mut [0; 2]) { + write!(writer, "\\u{unit:04x}")?; + } + start = index + ch.len_utf8(); + } + writer.write_all(&fragment.as_bytes()[start..]) + } } fn replace_existing_body_string( diff --git a/backend/crates/providers/openai/tests/provider/contract.rs b/backend/crates/providers/openai/tests/provider/contract.rs index 3b8f09675..0b366b617 100644 --- a/backend/crates/providers/openai/tests/provider/contract.rs +++ b/backend/crates/providers/openai/tests/provider/contract.rs @@ -2942,6 +2942,108 @@ async fn cross_account_scope_sanitizes_only_known_turn_metadata_fields() { ); } +#[tokio::test] +async fn websocket_account_scoping_preserves_ascii_turn_metadata_and_unicode_input() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_scope_same").await; + let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener"); + let base_url = format!("http://{}", listener.local_addr().expect("address")); + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.expect("accept websocket"); + let mut socket = accept_codex_test_websocket(socket).await; + let message = socket.next().await.expect("request").expect("valid frame"); + let body: Value = serde_json::from_str(message.to_text().expect("text")).expect("JSON"); + socket.send(Message::Text(json!({ + "type": "response.completed", + "response": {"id": "resp_ascii_metadata", "model": "gpt-5.4", "status": "completed", "output": []} + }).to_string().into())).await.expect("complete response"); + body + }); + // Official Codex keeps embedded turn metadata ASCII even for Unicode workspaces. + let raw = r#"{"installation_id":"client-installation","workspaces":{"C:\\Users\\\u9879\u76ee\\\ud83d\ude80":{"label":"caf\u00e9","literal":"\\u4e2d","quoted":"\"line\n"}}}"#; + let input = json!([{"role": "user", "content": "中文正文 🚀"}]); + let payload = ProtocolPayload::json_object( + "openai", + Map::from_iter([ + ("model".to_owned(), json!("gpt-5.4")), + ("input".to_owned(), input.clone()), + ( + "client_metadata".to_owned(), + json!({"x-codex-turn-metadata": raw}), + ), + ]), + ) + .expect("payload") + .with_context(Map::from_iter([("turn_metadata".to_owned(), json!(raw))])); + let mut stream = provider_with_base_url(&store, base_url) + .execute( + planned_request( + "openai", + Operation::Generate(GenerateRequest::from_protocol_payload(payload)), + ), + context_with_state_owner("req_ascii_metadata", "acct_scope_same"), + ) + .await + .expect("provider stream"); + while let Some(event) = stream.next().await { + event.expect("successful websocket response"); + } + let body = server.await.expect("server"); + let encoded = body + .pointer("/client_metadata/x-codex-turn-metadata") + .and_then(Value::as_str) + .expect("turn metadata"); + assert!(encoded.is_ascii(), "embedded header JSON must remain ASCII"); + let mut expected: Value = serde_json::from_str(raw).expect("original metadata"); + expected["installation_id"] = body["client_metadata"]["installation_id"].clone(); + assert_eq!( + serde_json::from_str::(encoded).expect("metadata JSON"), + expected + ); + assert_eq!(body["input"], input); +} + +#[tokio::test] +async fn http_account_scoping_keeps_unicode_metadata_ascii_in_headers_and_body() { + for owner in ["acct_scope_same", "acct_scope_old"] { + let raw = r#"{"installation_id":"client-installation","workspaces":{"/tmp/\u4e2d\u6587/\ud83d\ude80":{"label":"caf\u00e9"}}}"#; + let request = capture_scoped_http_request( + "req_ascii_http", + "acct_scope_same", + owner, + Map::from_iter([ + ("model".to_owned(), json!("gpt-5.4")), + ("input".to_owned(), json!("中文正文 🚀")), + ("turnMetadata".to_owned(), json!(raw)), + ( + "client_metadata".to_owned(), + json!({"x-codex-turn-metadata": raw}), + ), + ]), + Map::from_iter([("turn_metadata".to_owned(), json!(raw))]), + ) + .await; + let body = captured_request_body(&request); + let headers = captured_header_values(&request, "x-codex-turn-metadata"); + assert_eq!(headers.len(), 1); + let mut expected: Value = serde_json::from_str(raw).expect("original metadata"); + expected["installation_id"] = body["client_metadata"]["installation_id"].clone(); + for encoded in [ + std::str::from_utf8(&headers[0]).expect("UTF-8 header"), + body["turnMetadata"].as_str().expect("body turn metadata"), + body["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .expect("client metadata"), + ] { + assert!(encoded.is_ascii(), "scoped header JSON must remain ASCII"); + assert_eq!( + serde_json::from_str::(encoded).expect("metadata JSON"), + expected + ); + } + } +} + #[tokio::test] async fn cross_account_continuation_should_require_client_replay_without_an_upstream_probe() { let store = Arc::new(MemoryAccountStore::default());