diff --git a/backend/crates/providers/openai/src/transport/request.rs b/backend/crates/providers/openai/src/transport/request.rs index af338662a..53e311bb9 100644 --- a/backend/crates/providers/openai/src/transport/request.rs +++ b/backend/crates/providers/openai/src/transport/request.rs @@ -130,7 +130,7 @@ pub(crate) fn align_structured_location_fields( now: DateTime, location: &CodexRequestLocation, ) { - // 只改写带环境标记的日期和时区;epoch 时间戳保持绝对时间原值 + // 只改写完整环境上下文的日期和时区;epoch 时间戳保持绝对时间原值 let current_date = now .with_timezone(&location.timezone) .format("%Y-%m-%d") @@ -169,11 +169,10 @@ fn align_environment_context(item: &mut Value, current_date: &str, timezone: &st return; }; for (index, part) in content.iter_mut().enumerate() { - if content_kinds - .as_ref() - .and_then(|kinds| kinds.get(index)) - .and_then(Option::as_deref) - != Some(ENVIRONMENT_CONTEXT_CONTENT_KIND) + // 官方自定义 Provider 会移除内容分类,缺省时仍按完整环境上下文识别 + // 已有分类保持权威,不能覆盖显式标为普通文本或其他上下文的内容 + if let Some(kinds) = &content_kinds + && kinds.get(index).and_then(Option::as_deref) != Some(ENVIRONMENT_CONTEXT_CONTENT_KIND) { continue; } diff --git a/backend/crates/providers/openai/tests/provider/contract/mod.rs b/backend/crates/providers/openai/tests/provider/contract/mod.rs index 425964542..e1321b09c 100644 --- a/backend/crates/providers/openai/tests/provider/contract/mod.rs +++ b/backend/crates/providers/openai/tests/provider/contract/mod.rs @@ -773,7 +773,8 @@ async fn selected_proxy_location_overrides_global_and_reloads_without_mutating_c let provider = provider_with_base_url(&store, "http://upstream.invalid".to_owned()); let original = json!({"model":"gpt-5.4", "input":[ {"role":"user", "content":[{"type":"input_text", "text":"UTC"}], "internal_chat_message_metadata_passthrough":{"content_item_kinds":["environments.environment_context"], "create_time":1789293131.822}}, - {"role":"user", "content":[{"type":"input_text", "text":"UTC"}], "internal_chat_message_metadata_passthrough":{"content_item_kinds":["user.text"]}} + {"role":"user", "content":[{"type":"input_text", "text":"UTC"}], "internal_chat_message_metadata_passthrough":{"content_item_kinds":["user.text"]}}, + {"role":"user", "content":[{"type":"input_text", "text":"UTC"}]} ], "tools":[{"type":"web_search"}]}); let operation = Operation::Generate(GenerateRequest::from_protocol_payload( ProtocolPayload::json_object("openai", original.as_object().unwrap().clone()) @@ -797,6 +798,7 @@ async fn selected_proxy_location_overrides_global_and_reloads_without_mutating_c let expected = expected.unwrap_or("UTC"); assert_eq!(body.pointer("/input/0/content/0/text"), Some(&json!(format!("{expected}")))); assert_eq!(body.pointer("/input/1/content/0/text"), original.pointer("/input/1/content/0/text")); + assert_eq!(body.pointer("/input/2/content/0/text"), body.pointer("/input/0/content/0/text")); assert_eq!(body.pointer("/input/0/internal_chat_message_metadata_passthrough/create_time"), Some(&json!(1789293131.822))); } assert_eq!(first_proxy.received_requests().await.unwrap().len(), 2); @@ -1875,6 +1877,64 @@ async fn provider_should_send_the_request_snapshot_location_to_the_upstream() { ); } +#[tokio::test] +async fn provider_should_override_unclassified_environment_timezone_over_websocket() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").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_location", "model": "gpt-5.4", "status": "completed", "output": []} + }).to_string().into())).await.expect("complete response"); + body + }); + let environment = "/home/example/项目Asia/Shanghai"; + let payload = ProtocolPayload::json_object( + "openai", + json!({ + "model": "gpt-5.4", + "input": [{"type": "message", "role": "user", "content": [{"type": "input_text", "text": environment}]}], + "tools": [{"type": "web_search"}] + }).as_object().expect("request object").clone(), + ) + .expect("payload") + .with_context(Map::from_iter([("use_websocket".to_owned(), json!(true))])); + 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_ws_location", "acct_provider_contract"), + ) + .await + .expect("provider stream"); + while let Some(event) = stream.next().await { + event.expect("successful websocket response"); + } + let body = server.await.expect("server"); + assert_eq!( + body.pointer("/input/0/content/0/text"), + Some(&json!( + environment.replace("Asia/Shanghai", "Pacific/Auckland") + )) + ); + assert_eq!( + body.pointer("/tools/0/user_location/timezone"), + Some(&json!("Pacific/Auckland")) + ); + assert!( + body.pointer("/input/0/internal_chat_message_metadata_passthrough") + .is_none() + ); +} + async fn capture_turn_state_request( request_id: &str, previous_turn_id: Option<&str>, diff --git a/backend/crates/providers/openai/tests/transport/request.rs b/backend/crates/providers/openai/tests/transport/request.rs index 6acb7e8b7..75539e85d 100644 --- a/backend/crates/providers/openai/tests/transport/request.rs +++ b/backend/crates/providers/openai/tests/transport/request.rs @@ -94,6 +94,92 @@ fn encoder_should_preserve_location_fields_when_no_override_is_configured() { assert_eq!(encoded.body().get("model"), Some(&json!("gpt-test"))); } +#[test] +fn encoder_should_align_official_environment_context_without_content_kinds() { + let environment = "\n \n \ + /home/example/projectbash\n \ + \n 2020-01-01\n \ + Asia/Shanghai\n"; + for metadata in [None, Some(json!({"create_time": 1789293131.822}))] { + let mut message = json!({ + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "Please explain the current environment"}, + {"type": "input_text", "text": environment} + ] + }); + if let Some(metadata) = metadata { + message["internal_chat_message_metadata_passthrough"] = metadata; + } + let body = json!({"input": [message]}) + .as_object() + .expect("request object") + .clone(); + let before = Utc::now() + .with_timezone(&New_York) + .format("%Y-%m-%d") + .to_string(); + let encoded = encode_generate_request( + &request(body.clone()), + "gpt-test", + Some(&CodexRequestLocation::default()), + ) + .expect("encode unclassified official context"); + let after = Utc::now() + .with_timezone(&New_York) + .format("%Y-%m-%d") + .to_string(); + let encoded = Value::Object(encoded.body().clone()); + let aligned = encoded + .pointer("/input/0/content/1/text") + .and_then(Value::as_str) + .expect("aligned environment"); + assert!(aligned.contains("America/New_York")); + assert!([before, after].iter().any(|date| { + aligned + == environment + .replace("2020-01-01", date) + .replace("Asia/Shanghai", "America/New_York") + })); + let original = Value::Object(body.clone()); + assert_eq!( + encoded.pointer("/input/0/content/0"), + original.pointer("/input/0/content/0") + ); + assert_eq!( + encoded.pointer("/input/0/internal_chat_message_metadata_passthrough"), + original.pointer("/input/0/internal_chat_message_metadata_passthrough") + ); + + let unchanged = encode_generate_request(&request(body.clone()), "gpt-test", None) + .expect("encode without override"); + assert_eq!(unchanged.body().get("input"), body.get("input")); + } +} + +#[test] +fn encoder_should_preserve_classified_text_and_non_user_environment_items() { + let environment = + "Asia/Shanghai"; + let input = json!([ + {"role": "user", "content": [{"type": "input_text", "text": environment}], "internal_chat_message_metadata_passthrough": {"content_item_kinds": ["user.text"]}}, + {"role": "user", "content": [{"type": "input_text", "text": environment}], "internal_chat_message_metadata_passthrough": {"content_item_kinds": ["future.context"]}}, + {"role": "user", "content": [{"type": "input_text", "text": environment}], "internal_chat_message_metadata_passthrough": {"content_item_kinds": []}}, + {"role": "developer", "content": [{"type": "input_text", "text": environment}]}, + {"role": "assistant", "content": [{"type": "output_text", "text": environment}]}, + {"type": "function_call_output", "call_id": "synthetic", "output": environment}, + {"role": "user", "content": [{"type": "input_text", "text": "Asia/Shanghai"}]} + ]); + let encoded = encode_generate_request( + &request(Map::from_iter([("input".to_owned(), input.clone())])), + "gpt-test", + Some(&CodexRequestLocation::default()), + ) + .expect("encode without changing non-environment content"); + assert_eq!(encoded.body().get("input"), Some(&input)); +} + #[test] fn encoder_should_preserve_openai_wire_fields_without_deriving_accountless_pool_identity() { let body = Map::from_iter([ @@ -185,6 +271,7 @@ fn encoder_should_patch_model_and_preserve_supported_generate_semantics() { fn encoder_should_align_structured_location_fields_without_rewriting_chat_text() { let normal_chat = "\n 2026-09-13\n \ Asia/Shanghai\n"; + let quoted_context = format!("Please explain this example:\n```xml\n{normal_chat}\n```"); let body = json!({ "model": "client-model", "input": [ @@ -211,7 +298,7 @@ fn encoder_should_align_structured_location_fields_without_rewriting_chat_text() { "type": "message", "role": "user", - "content": [{"type": "input_text", "text": normal_chat}] + "content": [{"type": "input_text", "text": quoted_context}] } ], "tools": [ @@ -281,7 +368,7 @@ fn encoder_should_align_structured_location_fields_without_rewriting_chat_text() ); assert_eq!( encoded.pointer("/input/2/content/0/text"), - Some(&json!(normal_chat)) + Some(&json!(quoted_context)) ); assert_eq!( encoded.pointer("/input/0/internal_chat_message_metadata_passthrough/create_time"), diff --git a/deploy/README.md b/deploy/README.md index 6c12f9b24..7fc81472c 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -7,7 +7,7 @@ | 任务 | 入口 | | --- | --- | | 安装与启动 | [手动安装](#手动安装) · [启动](#启动) · [公网访问](#公网访问) | -| 接入客户端 | [客户端配置](#客户端配置) · [登录与生图排查](#登录与生图排查) | +| 接入客户端 | [客户端配置](#客户端配置) · [时区与客户端环境信息](#时区与客户端环境信息) · [登录与生图排查](#登录与生图排查) | | 日常运维 | [运行设置](#启动后的运行设置) · [小内存优化](#小内存优化) · [持久化与日志](#持久化与备份) · [请求排查](#请求错误排查) · [密码轮换](#密码语义) | | 更新与恢复 | [镜像升级](#镜像升级与源码构建) · [在线更新](#管理端在线更新) · [备份与恢复](#备份与恢复) · [优雅关停](#优雅关停) | | 运行源码 | [开发与源码联调](../docs/development.md) | @@ -362,6 +362,37 @@ OpenAI OAuth 账号的上游传输方式默认 WS;固定为 SSE 的账号不 `image_generation` 和 `goals` 仍有效。不要为了接入代理,顺带扩大命令沙箱的联网或文件权限 +### 时区与客户端环境信息 + +要覆盖模型请求中的位置和时区,在管理端启用全局自定义位置,或为账号关联的代理设置位置; +代理位置优先。`host.timezone` 控制部署日历和管理端时间展示,不代替请求位置设置。 +覆盖字段与环境消息识别条件见 [代理位置合同](../docs/api.md#独立代理管理--managed-proxies) + +Codex 回答仍显示本机时区时,先核对请求是否使用了预期账号和代理,再区分环境正文与工具结果: + +- 使用新会话询问环境上下文中的时区,避免已有工具输出或历史回答影响判断 +- 检查实际环境消息是否满足完整上下文形状;显式普通文本分类或非法 XML 不会被覆盖 +- 若模型调用 `date` 等工具查询本机,工具结果仍反映真实系统时区,覆盖不会修改客户端操作系统 + +网关的请求头画像和位置覆盖不提供完整匿名化。正文与工作区 metadata 的保留范围见 +[正文兼容](../docs/api.md#正文兼容),xAI 请求不应用 OpenAI 的请求位置覆盖 + +Codex 客户端还有独立的出站请求。analytics 使用 `chatgpt_base_url`,可以包含真实系统类型、 +系统版本和架构;模型 Provider 的 `base_url` 不改变该地址。已核对的官方 release 配置默认启用 +Statsig 指标,目标为 `https://ab.chatgpt.com/otlp/v1/metrics`,不由网关上游画像控制 + +需要关闭这两类遥测时,将以下字段合并到客户端 `config.toml`,避免重复创建已有表,完全退出并重启 Codex: + +```toml +[analytics] +enabled = false + +[otel] +metrics_exporter = "none" +``` + +这两个开关不清理模型请求中的工作目录、Git metadata、用户内容或工具结果。 + ### 登录与生图排查 - 仍提示登录:确认实际读取的用户配置目录、选中的 Provider 和客户端版本,再完全退出重启。 diff --git a/docs/api.md b/docs/api.md index 2b644f963..ad9de2b57 100644 --- a/docs/api.md +++ b/docs/api.md @@ -295,6 +295,10 @@ Codex/OAuth 上游的历史回填按字段形状兼容,不以 User-Agent 品 请求头过滤不提供客户端匿名化;系统提示词、工具定义、工具结果、工作目录及其他业务 metadata 保持原有语义,可能包含客户端环境信息。 +环境正文中的工作区路径、shell 和文件权限信息不会因位置覆盖而隐藏。 +turn metadata 的 `workspaces` 也会保留仓库绝对路径、Git 远端地址、提交及工作区变更状态; +安装身份和账号绑定字段的处理不提供这些信息的脱敏 + `client_metadata.parent_response_id` 是 Guardian 的账号内响应引用,只有归属可信且仍为同一账号时保留; 切号或归属未知时移除。`x-codex-guardian`、`guardian_credits_requested` 和序列化 `x-codex-turn-metadata` 内普通扩展的同名 `parent_response_id` 保持原样 @@ -755,11 +759,21 @@ API 的 `autoLocation` 默认为 `false`;开启时使用已检测位置,测 关联账号的 OpenAI/Codex Responses 请求(HTTP/SSE、WebSocket)优先使用代理位置,否则使用全局 运行设置中已开启的 `requestLocation`;两者均未开启时保留客户端原有位置和时区。全局覆盖按请求冻结, 新请求使用保存后的设置,无需重启;代理覆盖在每次执行时读取, -换号或换出口按该次选定账号解析。位置只影响带来源标记的环境上下文日期/时区和 Web Search 的结构化位置, -不改变用户普通文本、epoch 时间戳、真实出口 IP、服务或管理端时区、数据驻留约束及 xAI 请求 +换号或换出口按该次选定账号解析。位置只影响环境上下文日期/时区和 Web Search 的结构化位置, +不改变 epoch 时间戳、真实出口 IP、服务或管理端时区、数据驻留约束及 xAI 请求 + +环境消息要求 `role: "user"`,文本块为 `type: "input_text"`,完整文本去除首尾空白后由 +`` 与 `` 包围且为合法 XML;只替换根节点直属的 +`current_date` 和 `timezone`。有 `internal_chat_message_metadata_passthrough.content_item_kinds` 数组时, +仅处理对应分类为 `environments.environment_context` 的文本块;没有分类时,按完整环境上下文识别。 +显式标为 `user.text` 或其他分类的内容、普通聊天中引用的示例、工具结果及无法解析的上下文保持原样。 +客户端实际本机时区不受影响,工具读取本机时区后的输出仍可包含真实值;排查见 +[时区与客户端环境信息](../deploy/README.md#时区与客户端环境信息) 测试经代理并发访问 IPv4 专用端点 `https://api.ipify.org?format=json` 与 IPv6 专用端点 `https://api6.ipify.org?format=json`, 分别验证并记录双栈出口(IPv4 与 IPv6 地址),在任一地址族可用时即判定连接成功。超时 15 秒,每进程最多同时测试 4 条。 +解析位置时还会向 `https://ipwho.is/<出口 IP>` 查询地理位置,出口 IP 会发送给该第三方服务; +自定义位置和时区不改变这些探测请求 探测器复用 OpenAI 的证书信任配置:优先读取非空的 `CODEX_CA_CERTIFICATE`, 其次读取 `SSL_CERT_FILE`,并保留系统根证书;证书配置错误不会回退为不验证证书。 出口测试结果仅供诊断,不限制代理的选择和绑定;未测试或测试失败的代理仍可使用。