diff --git a/backend/crates/providers/openai/src/transport/automatic_context.rs b/backend/crates/providers/openai/src/transport/automatic_context.rs new file mode 100644 index 000000000..6c7f5979b --- /dev/null +++ b/backend/crates/providers/openai/src/transport/automatic_context.rs @@ -0,0 +1,146 @@ +//! 自动上下文定位与 Desktop Git XML/JSON 编解码,供画像与隐私规则共用 + +use serde_json::Value; + +pub(super) struct ContextTarget { + pub(super) path: String, + open: &'static str, + close: &'static str, + pub(super) identified: bool, +} + +impl ContextTarget { + pub(super) fn is_complete(&self, text: &str) -> bool { + let trimmed = text.trim(); + trimmed.starts_with(self.open) && trimmed.ends_with(self.close) + } +} + +pub(super) fn locate_contexts( + body: &Value, + desktop: bool, + mut visit: impl FnMut() -> Result<(), &'static str>, +) -> Result, &'static str> { + let mut targets = Vec::new(); + let Some(items) = body.get("input").and_then(Value::as_array) else { + return Ok(targets); + }; + for (item_index, item) in items.iter().enumerate() { + visit()?; + let role = item.get("role").and_then(Value::as_str).unwrap_or(""); + if !(role == "user" || (!desktop && role == "developer")) { + continue; + } + let (kind, open, close) = if desktop { + ( + "additional_content.codex_git_state", + "", + "", + ) + } else if role == "developer" { + ( + "additional_content.codex_apps_client_time_context", + "", + "", + ) + } else { + ( + "environments.environment_context", + "", + "", + ) + }; + let kinds = item.pointer("/internal_chat_message_metadata_passthrough/content_item_kinds"); + let Some(parts) = item.get("content").and_then(Value::as_array) else { + continue; + }; + for (index, part) in parts.iter().enumerate() { + visit()?; + if kinds.is_some_and(|kinds| kinds.get(index).and_then(Value::as_str) != Some(kind)) { + continue; + } + if part.get("type").and_then(Value::as_str) != Some("input_text") { + continue; + } + let Some(text) = part.get("text").and_then(Value::as_str) else { + continue; + }; + let trimmed = text.trim(); + if kinds.is_none() && !(trimmed.starts_with(open) && trimmed.ends_with(close)) { + continue; + } + targets.push(ContextTarget { + path: format!("/input/{item_index}/content/{index}/text"), + open, + close, + identified: kinds.is_some(), + }); + } + } + Ok(targets) +} + +pub(super) fn rewrite_git_context( + text: &mut String, + max_bytes: usize, + edit: impl FnOnce(&mut Value) -> Result, +) -> Result { + let document = parse_xml(text)?; + let root = document.root_element(); + if root.tag_name().name() != "external_codex_git_state" { + return Err("Desktop Git 包裹不匹配"); + } + let nodes: Vec<_> = root + .descendants() + .filter(|node| { + node.has_tag_name("git") + && node + .parent() + .is_some_and(|p| p.has_tag_name("environment_context")) + }) + .collect(); + if nodes.len() != 1 { + return Err("Desktop Git 数据节点不明确"); + } + let git = nodes[0]; + if git.children().any(|node| !node.is_text()) { + return Err("Desktop Git 数据不是文本"); + } + let raw: String = git.children().filter_map(|node| node.text()).collect(); + let mut value: Value = serde_json::from_str(&raw).map_err(|_| "Desktop Git JSON 无法解析")?; + if !value.is_object() { + return Err("Desktop Git JSON 必须是对象"); + } + let count = edit(&mut value)?; + if count > 0 { + let range = git.range(); + let fragment = &text[range.clone()]; + let start = git + .first_child() + .ok_or("Desktop Git 数据为空")? + .range() + .start; + let end = range.start + fragment.rfind("', ">"); + if text.len() - (end - start) + escaped.len() > max_bytes { + return Err("Desktop Git 编码结果超出大小限制"); + } + text.replace_range(start..end, &escaped); + } + Ok(count) +} + +pub(super) fn parse_xml(text: &str) -> Result, &'static str> { + roxmltree::Document::parse_with_options( + text, + roxmltree::ParsingOptions { + nodes_limit: 10_000, + ..Default::default() + }, + ) + .map_err(|_| "上下文 XML 无法解析或节点数量超限") +} diff --git a/backend/crates/providers/openai/src/transport/client_sse.rs b/backend/crates/providers/openai/src/transport/client_sse.rs index 2dc5fa61d..7f4c594b3 100644 --- a/backend/crates/providers/openai/src/transport/client_sse.rs +++ b/backend/crates/providers/openai/src/transport/client_sse.rs @@ -114,7 +114,9 @@ impl CodexBackendClient { upstream_request: &CodexResponsesRequest, context: CodexRequestContext<'_>, ) -> CodexClientResult { - let mut headers = self.request_headers_for_http_response(upstream_request, context)?; + let profile = self.profile.snapshot(); + let mut headers = + self.request_headers_for_http_response(&profile, upstream_request, context)?; let headers_started_at = Instant::now(); // OAuth 请求遵循 Codex 压缩合同;API Key 上游使用普通 JSON // Codex 上游只交付 SSE;即使下游请求 `stream: false`,也要上游流式执行, @@ -127,6 +129,7 @@ impl CodexBackendClient { if self.protocol == OpenAiUpstreamProtocol::Codex { headers.insert(CONTENT_ENCODING, HeaderValue::from_static("zstd")); } + profile.apply_context_versions(&mut upstream_body); self.apply_privacy(&mut upstream_body, &mut headers)?; let body = serde_json::to_vec(&upstream_body).map_err(CodexClientError::RequestBodyEncode)?; @@ -284,10 +287,12 @@ impl CodexBackendClient { } let mut websocket_request = websocket_upstream_request(request); + let profile = self.profile.snapshot(); let mut headers = - self.request_headers_for_websocket_response(&websocket_request, context)?; + self.request_headers_for_websocket_response(&profile, &websocket_request, context)?; let mut body = serde_json::Value::Object(std::mem::take(websocket_request.body_mut())); let original_headers = self.privacy.as_ref().map(|_| headers.clone()); + profile.apply_context_versions(&mut body); self.apply_privacy(&mut body, &mut headers)?; *websocket_request.body_mut() = body.as_object().cloned().ok_or_else(|| { CodexClientError::Privacy(gateway_core::settings::privacy::PrivacyError { diff --git a/backend/crates/providers/openai/src/transport/headers.rs b/backend/crates/providers/openai/src/transport/headers.rs index e2fba37d9..e0d90c87b 100644 --- a/backend/crates/providers/openai/src/transport/headers.rs +++ b/backend/crates/providers/openai/src/transport/headers.rs @@ -143,10 +143,11 @@ impl CodexBackendClient { pub(super) fn request_headers_for_http_response( &self, + profile: &CodexWireProfile, request: &CodexResponsesRequest, context: CodexRequestContext<'_>, ) -> CodexClientResult { - let mut headers = self.response_headers(request, context)?; + let mut headers = self.response_headers(profile, request, context)?; headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream")); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); insert_optional_protocol_header( @@ -159,10 +160,11 @@ impl CodexBackendClient { pub(super) fn request_headers_for_websocket_response( &self, + profile: &CodexWireProfile, request: &CodexResponsesRequest, context: CodexRequestContext<'_>, ) -> CodexClientResult { - let mut headers = self.response_headers(request, context)?; + let mut headers = self.response_headers(profile, request, context)?; if !headers.contains_key("x-responsesapi-include-timing-metrics") { headers.insert( HeaderName::from_static("x-responsesapi-include-timing-metrics"), @@ -179,10 +181,11 @@ impl CodexBackendClient { /// HTTP 和 WS 共用的 Core 会话字段;传输专属字段由各自入口生成 fn response_headers( &self, + profile: &CodexWireProfile, request: &CodexResponsesRequest, context: CodexRequestContext<'_>, ) -> CodexClientResult { - let mut headers = self.model_request_headers(&self.profile.snapshot(), context)?; + let mut headers = self.model_request_headers(profile, context)?; headers.insert( HeaderName::from_static("x-client-request-id"), HeaderValue::from_str(context.request_id)?, diff --git a/backend/crates/providers/openai/src/transport/mod.rs b/backend/crates/providers/openai/src/transport/mod.rs index c23657cc6..520e2666d 100644 --- a/backend/crates/providers/openai/src/transport/mod.rs +++ b/backend/crates/providers/openai/src/transport/mod.rs @@ -1,5 +1,6 @@ //! Codex HTTP/SSE/WebSocket 上游 transport +mod automatic_context; pub mod canonical; pub mod catalog; pub mod client; diff --git a/backend/crates/providers/openai/src/transport/privacy/carrier.rs b/backend/crates/providers/openai/src/transport/privacy/carrier.rs index d28e836e7..3b1e43968 100644 --- a/backend/crates/providers/openai/src/transport/privacy/carrier.rs +++ b/backend/crates/providers/openai/src/transport/privacy/carrier.rs @@ -5,6 +5,9 @@ use reqwest::header::{HeaderMap, HeaderValue}; use serde_json::Value; use super::{Budget, changes::BodyChanges, rule::CompiledStep}; +use crate::transport::automatic_context::{ + ContextTarget, locate_contexts, parse_xml, rewrite_git_context, +}; const METADATA_KEYS: &[&str] = &["turnMetadata", "turn_metadata", "x-codex-turn-metadata"]; @@ -133,13 +136,6 @@ pub(super) struct ContextIndex { environment: Option>, } -struct ContextTarget { - path: String, - open: &'static str, - close: &'static str, - identified: bool, -} - impl ContextIndex { fn apply( &mut self, @@ -155,7 +151,7 @@ impl ContextIndex { &mut self.environment }; if targets.is_none() { - *targets = Some(locate_contexts(body, desktop, budget)?); + *targets = Some(locate_contexts(body, desktop, || budget.step())?); } let mut count = 0; for target in targets.iter().flatten() { @@ -165,9 +161,7 @@ impl ContextIndex { .pointer(&target.path) .and_then(Value::as_str) .ok_or("上下文文本不存在")?; - let trimmed = text.trim(); - let complete = trimmed.starts_with(target.open) && trimmed.ends_with(target.close); - if !complete { + if !target.is_complete(text) { if target.identified { return Err("已识别上下文不完整,可能已被截断"); } @@ -179,7 +173,10 @@ impl ContextIndex { return Err("上下文必须是文本"); }; if desktop { - git(rule, text, budget) + budget.step()?; + rewrite_git_context(text, super::MAX_VALUE_BYTES, |value| { + rule.apply(value, budget) + }) } else { environment(rule, text, budget) } @@ -189,125 +186,6 @@ impl ContextIndex { } } -fn locate_contexts( - body: &Value, - desktop: bool, - budget: &mut Budget<'_>, -) -> Result, &'static str> { - let mut targets = Vec::new(); - let Some(items) = body.get("input").and_then(Value::as_array) else { - return Ok(targets); - }; - for (item_index, item) in items.iter().enumerate() { - budget.step()?; - let role = item.get("role").and_then(Value::as_str).unwrap_or(""); - if !(role == "user" || (!desktop && role == "developer")) { - continue; - } - let (kind, open, close) = if desktop { - ( - "additional_content.codex_git_state", - "", - "", - ) - } else if role == "developer" { - ( - "additional_content.codex_apps_client_time_context", - "", - "", - ) - } else { - ( - "environments.environment_context", - "", - "", - ) - }; - let kinds = item.pointer("/internal_chat_message_metadata_passthrough/content_item_kinds"); - let Some(parts) = item.get("content").and_then(Value::as_array) else { - continue; - }; - for (index, part) in parts.iter().enumerate() { - budget.step()?; - if kinds.is_some_and(|kinds| kinds.get(index).and_then(Value::as_str) != Some(kind)) { - continue; - } - if part.get("type").and_then(Value::as_str) != Some("input_text") { - continue; - } - let Some(text) = part.get("text").and_then(Value::as_str) else { - continue; - }; - let trimmed = text.trim(); - if kinds.is_none() && !(trimmed.starts_with(open) && trimmed.ends_with(close)) { - continue; - } - targets.push(ContextTarget { - path: format!("/input/{item_index}/content/{index}/text"), - open, - close, - identified: kinds.is_some(), - }); - } - } - Ok(targets) -} - -fn git( - rule: &CompiledStep, - text: &mut String, - budget: &mut Budget<'_>, -) -> Result { - let document = parse_xml(text)?; - budget.step()?; - let root = document.root_element(); - if root.tag_name().name() != "external_codex_git_state" { - return Err("Desktop Git 包裹不匹配"); - } - let nodes: Vec<_> = root - .descendants() - .filter(|node| { - node.has_tag_name("git") - && node - .parent() - .is_some_and(|p| p.has_tag_name("environment_context")) - }) - .collect(); - if nodes.len() != 1 { - return Err("Desktop Git 数据节点不明确"); - } - let git = nodes[0]; - if git.children().any(|node| !node.is_text()) { - return Err("Desktop Git 数据不是文本"); - } - let raw: String = git.children().filter_map(|node| node.text()).collect(); - let mut value: Value = serde_json::from_str(&raw).map_err(|_| "Desktop Git JSON 无法解析")?; - if !value.is_object() { - return Err("Desktop Git JSON 必须是对象"); - } - let count = rule.apply(&mut value, budget)?; - if count > 0 { - let range = git.range(); - let fragment = &text[range.clone()]; - let start = git - .first_child() - .ok_or("Desktop Git 数据为空")? - .range() - .start; - let end = range.start + fragment.rfind("', ">"); - if text.len() - (end - start) + escaped.len() > super::MAX_VALUE_BYTES { - return Err("Desktop Git 编码结果超出大小限制"); - } - text.replace_range(start..end, &escaped); - } - Ok(count) -} - fn environment( rule: &CompiledStep, text: &mut String, @@ -324,17 +202,6 @@ fn environment( Ok(count) } -fn parse_xml(text: &str) -> Result, &'static str> { - roxmltree::Document::parse_with_options( - text, - roxmltree::ParsingOptions { - nodes_limit: 10_000, - ..Default::default() - }, - ) - .map_err(|_| "上下文 XML 无法解析或节点数量超限") -} - fn xml_shape(text: &str, budget: &mut Budget<'_>) -> Result, &'static str> { let document = parse_xml(text)?; let mut shape = Vec::new(); diff --git a/backend/crates/providers/openai/src/transport/profile/context.rs b/backend/crates/providers/openai/src/transport/profile/context.rs new file mode 100644 index 000000000..1374ffb55 --- /dev/null +++ b/backend/crates/providers/openai/src/transport/profile/context.rs @@ -0,0 +1,51 @@ +//! 将自动 Git 上下文的客户端版本与本次请求画像同步,保留项目和会话状态 + +use serde_json::Value; + +use super::{CodexWireProfile, selection::ClientKind}; +use crate::transport::automatic_context::{locate_contexts, rewrite_git_context}; + +impl CodexWireProfile { + pub(crate) fn apply_context_versions(&self, body: &mut Value) { + // 与隐私规则共享承载识别;不扫描普通对话、工具输出或嵌入的引用片段 + let Ok(targets) = locate_contexts(body, true, || Ok(())) else { + return; + }; + for target in targets { + let Some(Value::String(text)) = body.pointer_mut(&target.path) else { + continue; + }; + if text.len() > 1024 * 1024 || !target.is_complete(text) { + continue; + } + // 官方 Desktop 26.1007.71326 的 codex_git_state 两个版本字段均可为空 + // 未知或损坏上下文保持原样,显式隐私规则仍按自己的失败策略执行 + let _ = rewrite_git_context(text, 1024 * 1024, |value| { + let mut changed = 0; + for (field, version) in [ + ("appServerVersion", Some(self.codex_version.as_str())), + ( + "appVersion", + (self.client_kind == ClientKind::Desktop + && !self.desktop_version.is_empty()) + .then_some(self.desktop_version.as_str()), + ), + ] { + let Some(current) = value.get_mut(field) else { + continue; + }; + if !(current.is_string() || current.is_null()) { + continue; + } + let next = + version.map_or(Value::Null, |version| Value::String(version.to_owned())); + if *current != next { + *current = next; + changed += 1; + } + } + Ok(changed) + }); + } + } +} diff --git a/backend/crates/providers/openai/src/transport/profile/mod.rs b/backend/crates/providers/openai/src/transport/profile/mod.rs index 367fa474c..7a9f21096 100644 --- a/backend/crates/providers/openai/src/transport/profile/mod.rs +++ b/backend/crates/providers/openai/src/transport/profile/mod.rs @@ -21,6 +21,7 @@ use url::Url; use self::desktop_artifact::{CodexDesktopArtifactError, fetch_codex_core_version}; pub mod cli_release; +mod context; pub mod desktop_artifact; pub mod identity; pub mod platform_release; diff --git a/docs/api.md b/docs/api.md index b8a15c282..5e4d09a45 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1457,9 +1457,11 @@ JSON 路径支持 `$`、`.成员`、`["带特殊字符的键"]`、`[索引]`、` `$.workspaces.*.associated_remote_urls.*` 配合 `remove_field` 和 `pattern: "private-org"` 只删除命中的远端; `$.workspaces` 配合 `rename_key` 可改写工作区路径键名。环境文本替换必须保留 XML 包裹结构 -管理端提供「统一工作区信息」和「统一 Desktop 信息」两个预设,各自生成一条含多个通用步骤的规则。 -工作区预设统一路径中的用户名为 `user`,工作区路径键和环境工作目录保留实际目录名并改为 `/workspace/<项目名>`; -Desktop 预设统一远端集合、提交标识、工作区指纹和版本。预设和自定义规则使用同一编辑器,步骤均可增删、修改和排序 +管理端提供「统一工作区信息」预设,生成一条含多个通用步骤的规则。 +预设统一路径中的用户名为 `user`,工作区路径键和环境工作目录保留实际目录名并改为 `/workspace/<项目名>`, +保留实际仓库、提交、分支、工作区标识和会话关联信息。安装身份由账号隔离逻辑处理, +UA、平台参数及自动 Git 上下文版本由 [OpenAI 上游客户端身份](#openai-上游客户端身份)管理。 +预设和自定义规则使用同一编辑器,步骤均可增删、修改和排序;调整预设不覆盖已保存的规则 一条规则内的所有步骤和承载副本共同提交或回滚。已有多条规则不会自动合并或被覆盖。目标为 `null` 时仍遵循类型校验与失败策略,路径键重名时失败,不合并不同工作区 @@ -1678,6 +1680,11 @@ Windows/Linux 通过 ETag 检查更新,未变化时复用已核验版本;CLI WebSocket 使用 rustls;配置自定义 CA 时 HTTP 也使用 rustls。TLS 指纹需按实际部署平台与传输路径核验。 未完成本次启动检查时 `checkedAt` 为 `null`。非法或当前不可用的选择返回 `400`,保存失败不提交其他修改 +Responses 的自动 Desktop Git 上下文与请求头使用同一画像快照:已有 `appServerVersion` 同步为 Core 版本, +`appVersion` 同步为 Desktop 版本;CLI 或自定义 UA 未提供 Desktop 版本时使用 `null`。 +只处理完整、可解析的自动上下文,保留 Git 状态、会话字段和普通对话,不补充缺少的版本字段。 +该同步不依赖隐私策略开关;显式隐私规则在其后执行,仍可覆盖最终字段 + 配置在请求开始时冻结,Provider 首次解析的版本用于该请求的全部重试与换号。 已建立 WebSocket 的精确续写沿用所属连接;新请求使用保存后的选择 diff --git a/frontend/src/views/settings/components/privacy/presets.ts b/frontend/src/views/settings/components/privacy/presets.ts index bfdb1bed8..8a55e545d 100644 --- a/frontend/src/views/settings/components/privacy/presets.ts +++ b/frontend/src/views/settings/components/privacy/presets.ts @@ -27,8 +27,6 @@ export const fieldsByScope: Partial[] steps: [ { scope: 'turn_metadata', selector: '$.workspaces', action: 'rename_key', pattern: String.raw`(?i)^((?:[a-z]:)?[/\\](?:home|users)[/\\])[^/\\]+`, replacement: `\${1}user` }, { scope: 'environment_text', selector: '$', action: 'regex_replace', pattern: String.raw`(?i)((?:[a-z]:)?[/\\](?:home|users)[/\\])[^/\\<>"'\r\n]+`, replacement: `\${1}user` }, - // 项目名取自原路径,不固定到预设样本中的项目 + // 路径隐藏本机目录布局,项目名和 Git 状态仍取自实际请求 { scope: 'turn_metadata', selector: '$.workspaces', action: 'rename_key', pattern: String.raw`(?s)^(?:[a-zA-Z]:)?[/\\](?:.*[/\\])?([^/\\]+)[/\\]*$`, replacement: `/workspace/\${1}` }, { scope: 'environment_text', selector: '$', action: 'regex_replace', pattern: String.raw`(\s*)(?:[a-zA-Z]:)?[/\\](?:[^<>\r\n]*[/\\])?([^/\\<>\r\n]+?)[/\\]*\s*()`, replacement: `\${1}/workspace/\${2}\${3}` }, - { scope: 'turn_metadata', selector: '$.workspaces.*.associated_remote_urls.*', action: 'set_value', pattern: null, value: 'https://example.invalid/repo.git' }, - { scope: 'turn_metadata', selector: '$.workspaces.*.latest_git_commit_hash', action: 'set_value', pattern: null, value: '0'.repeat(40) }, - ], - }, - { - key: 'desktop', - name: '统一 Desktop 信息', - steps: [ - // 统一整个远端集合,避免远端数量、名称和可空 URL 继续暴露差异 - { scope: 'desktop_git_context', selector: '$.remotes', action: 'set_value', pattern: null, value: [{ name: 'origin', fetchUrl: 'https://example.invalid/repo.git' }] }, - { scope: 'desktop_git_context', selector: '$.headCommitSha', action: 'set_value', pattern: null, value: '0'.repeat(40) }, - { scope: 'desktop_git_context', selector: '$.worktreeId', action: 'set_value', pattern: null, value: '0'.repeat(40) }, - { scope: 'desktop_git_context', selector: '$.appVersion', action: 'set_value', pattern: null, value: '0.0.0' }, - { scope: 'desktop_git_context', selector: '$.appServerVersion', action: 'set_value', pattern: null, value: '0.0.0' }, ], }, ]