From f8480f3bdcfa80c741f9878149213bac6ca248f3 Mon Sep 17 00:00:00 2001 From: Zyy Date: Thu, 15 Oct 2026 16:21:10 +0800 Subject: [PATCH] fix(privacy): preserve project state in identity presets Keep repository, commit, branch and worktree data intact while normalizing personal paths. Remove the Desktop identity preset and version shortcuts from privacy settings. Synchronize automatic Desktop Git context versions from the existing request-scoped client identity in HTTP and WebSocket requests. Reuse official release updates, fixed versions and key overrides, and share context parsing with privacy rules. Preserve unknown or incomplete context and leave installation identity to its existing account owner. Verified version field semantics against the official Desktop 26.1007.71326 bundle and Codex app-server initialization source. Validated provider tests, temporary HTTP/WS identity tests, architecture boundaries, strict Clippy, frontend lint/build and the preset UI. Co-authored-by: Codex --- .../openai/src/transport/automatic_context.rs | 146 +++++++++++++++++ .../openai/src/transport/client_sse.rs | 9 +- .../providers/openai/src/transport/headers.rs | 9 +- .../providers/openai/src/transport/mod.rs | 1 + .../openai/src/transport/privacy/carrier.rs | 151 ++---------------- .../openai/src/transport/profile/context.rs | 51 ++++++ .../openai/src/transport/profile/mod.rs | 1 + docs/api.md | 13 +- .../settings/components/privacy/presets.ts | 18 +-- 9 files changed, 232 insertions(+), 167 deletions(-) create mode 100644 backend/crates/providers/openai/src/transport/automatic_context.rs create mode 100644 backend/crates/providers/openai/src/transport/profile/context.rs 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' }, ], }, ]