From 78b2250faa70881e8657bd99bfaf4a39d0e4d5a6 Mon Sep 17 00:00:00 2001 From: Zyy <39424122+zyycn@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:41:25 +0800 Subject: [PATCH] feat(settings): add configurable Codex privacy policies (#375) Add configurable privacy rules and shared previews for Codex metadata, Desktop context, request bodies and headers. Freeze policy per request and preserve atomic failure handling across HTTP and WebSocket transports. Include settings presets, persistence, API contracts, migration and transport regressions. Co-authored-by: Codex --- backend/Cargo.lock | 1 + .../gateway-admin/src/model/settings.rs | 1 + .../gateway-admin/src/ports/provider.rs | 26 ++ .../src/public_service/settings.rs | 7 + .../gateway-admin/src/use_case/settings.rs | 33 ++ .../gateway-admin/tests/use_case/accounts.rs | 1 + .../tests/use_case/freeze_recovery.rs | 1 + .../tests/use_case/observability.rs | 1 + .../tests/use_case/service_contract.rs | 25 ++ .../gateway-admin/tests/use_case/settings.rs | 1 + .../crates/gateway-api/src/admin/settings.rs | 27 ++ backend/crates/gateway-api/tests/admin/mod.rs | 2 + .../gateway-api/tests/admin/settings.rs | 61 +++ .../gateway-core/src/engine/coordinator.rs | 1 + backend/crates/gateway-core/src/engine/mod.rs | 16 + .../gateway-core/src/engine/provider.rs | 42 ++ backend/crates/gateway-core/src/lib.rs | 3 +- .../crates/gateway-core/src/routing/mod.rs | 7 + .../gateway-core/src/routing/snapshot.rs | 58 ++- .../gateway-core/src/settings/compiled.rs | 31 +- .../crates/gateway-core/src/settings/mod.rs | 1 + .../gateway-core/src/settings/privacy.rs | 134 ++++++ .../gateway-core/src/settings/values.rs | 8 + .../crates/gateway-core/tests/routing/mod.rs | 1 + .../gateway-core/tests/routing/privacy.rs | 125 ++++++ .../sdk/src/call/services/settings.rs | 80 ++++ .../crates/gateway-store/src/admin_adapter.rs | 1 + .../src/postgres/runtime_settings.rs | 9 +- .../gateway-store/src/postgres/snapshot.rs | 5 +- .../tests/postgres/runtime_settings.rs | 66 ++- backend/crates/providers/openai/Cargo.toml | 1 + backend/crates/providers/openai/src/admin.rs | 20 + .../openai/src/provider/execution.rs | 3 +- .../providers/openai/src/provider/failure.rs | 12 + .../providers/openai/src/provider/mod.rs | 13 +- .../providers/openai/src/transport/client.rs | 10 +- .../openai/src/transport/client_json.rs | 17 +- .../openai/src/transport/client_sse.rs | 64 ++- .../providers/openai/src/transport/mod.rs | 2 + .../openai/src/transport/privacy/carrier.rs | 288 +++++++++++++ .../openai/src/transport/privacy/mod.rs | 275 +++++++++++++ .../openai/src/transport/privacy/rule.rs | 227 +++++++++++ .../openai/src/transport/privacy/selector.rs | 113 +++++ .../providers/openai/src/transport/request.rs | 20 +- .../openai/tests/provider/contract/mod.rs | 1 + .../openai/tests/provider/contract/privacy.rs | 331 +++++++++++++++ .../providers/openai/tests/transport/mod.rs | 1 + .../openai/tests/transport/privacy/mod.rs | 385 ++++++++++++++++++ backend/migrations/.frozen-sha256 | 1 + .../migrations/0026_codex_privacy_policy.sql | 11 + docs/api.md | 43 ++ docs/architecture.md | 6 + frontend/src/api/modules/settings/index.ts | 3 + frontend/src/api/modules/settings/privacy.ts | 46 +++ .../components/AccountAffinityCard.vue | 6 +- .../components/AccountAutoFreezeCard.vue | 12 +- .../components/ConcurrencyPolicyCard.vue | 6 +- .../components/SmartSchedulingModal.vue | 10 +- .../components/ClientVersionPolicyCard.vue | 12 +- .../components/privacy/PolicyPreview.vue | 60 +++ .../components/privacy/PrivacyPolicyCard.vue | 116 ++++++ .../components/privacy/RuleEditor.vue | 208 ++++++++++ .../components/privacy/RulesTable.vue | 82 ++++ .../settings/components/privacy/presets.ts | 120 ++++++ .../settings/composables/usePrivacyPreview.ts | 28 ++ .../settings/composables/useSettingsForm.ts | 5 +- frontend/src/views/settings/index.vue | 2 + .../views/settings/pricing/PricingEditor.vue | 8 +- .../views/settings/pricing/PricingUnit.vue | 10 +- 69 files changed, 3289 insertions(+), 64 deletions(-) create mode 100644 backend/crates/gateway-core/src/settings/privacy.rs create mode 100644 backend/crates/gateway-core/tests/routing/privacy.rs create mode 100644 backend/crates/providers/openai/src/transport/privacy/carrier.rs create mode 100644 backend/crates/providers/openai/src/transport/privacy/mod.rs create mode 100644 backend/crates/providers/openai/src/transport/privacy/rule.rs create mode 100644 backend/crates/providers/openai/src/transport/privacy/selector.rs create mode 100644 backend/crates/providers/openai/tests/provider/contract/privacy.rs create mode 100644 backend/crates/providers/openai/tests/transport/privacy/mod.rs create mode 100644 backend/migrations/0026_codex_privacy_policy.sql create mode 100644 frontend/src/api/modules/settings/privacy.ts create mode 100644 frontend/src/views/settings/components/privacy/PolicyPreview.vue create mode 100644 frontend/src/views/settings/components/privacy/PrivacyPolicyCard.vue create mode 100644 frontend/src/views/settings/components/privacy/RuleEditor.vue create mode 100644 frontend/src/views/settings/components/privacy/RulesTable.vue create mode 100644 frontend/src/views/settings/components/privacy/presets.ts create mode 100644 frontend/src/views/settings/composables/usePrivacyPreview.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 43bad2e09..959aee5b8 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -3289,6 +3289,7 @@ dependencies = [ "native-tls", "openssl", "openssl-sys", + "regex", "reqwest", "roxmltree", "rustls", diff --git a/backend/crates/gateway-admin/src/model/settings.rs b/backend/crates/gateway-admin/src/model/settings.rs index 5a5e7d6df..bc587685e 100644 --- a/backend/crates/gateway-admin/src/model/settings.rs +++ b/backend/crates/gateway-admin/src/model/settings.rs @@ -45,6 +45,7 @@ pub use gateway_core::account::RotationStrategy; /// 运行设置的共同值;版本、Provider 画像更新和数据库秘密由外层类型拥有 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct RuntimeSettingsValues { + pub codex_privacy_policy: gateway_core::settings::privacy::CodexPrivacyPolicy, pub request_location_enabled: bool, pub request_location: gateway_core::account::RequestLocation, pub refresh_margin_seconds: u64, diff --git a/backend/crates/gateway-admin/src/ports/provider.rs b/backend/crates/gateway-admin/src/ports/provider.rs index 0079f2610..a34c3e570 100644 --- a/backend/crates/gateway-admin/src/ports/provider.rs +++ b/backend/crates/gateway-admin/src/ports/provider.rs @@ -130,6 +130,32 @@ struct ProviderAdminDiagnostic { /// 运行时资源通知只在事务成功后发生 #[async_trait] pub trait ProviderAdmin: Send + Sync { + fn compile_privacy_policy( + &self, + _policy: &gateway_core::settings::privacy::CodexPrivacyPolicy, + ) -> Result< + Arc, + gateway_core::settings::privacy::PrivacyError, + > { + Err(gateway_core::settings::privacy::PrivacyError { + rule_index: 0, + reason: "Provider 不支持隐私策略", + }) + } + + fn preview_privacy_policy( + &self, + _request: gateway_core::settings::privacy::PrivacyPreviewRequest, + ) -> Result< + gateway_core::settings::privacy::PrivacyPreviewResult, + gateway_core::settings::privacy::PrivacyError, + > { + Err(gateway_core::settings::privacy::PrivacyError { + rule_index: 0, + reason: "Provider 不支持隐私策略", + }) + } + /// 只读内置价目;没有本地计价能力的 Provider 返回空目录 fn pricing_catalog(&self) -> crate::model::pricing::ProviderPricingCatalog { Default::default() diff --git a/backend/crates/gateway-admin/src/public_service/settings.rs b/backend/crates/gateway-admin/src/public_service/settings.rs index fa768f68e..5d667a816 100644 --- a/backend/crates/gateway-admin/src/public_service/settings.rs +++ b/backend/crates/gateway-admin/src/public_service/settings.rs @@ -25,6 +25,13 @@ pub(super) fn register( }}; } register!(Load, load, (), ()); + register!( + PreviewPrivacyPolicy, + preview_privacy_policy, + gateway_core::settings::privacy::PrivacyPreviewRequest, + request, + request + ); register!( Replace, replace, diff --git a/backend/crates/gateway-admin/src/use_case/settings.rs b/backend/crates/gateway-admin/src/use_case/settings.rs index edd181c4a..fd7ef8a58 100644 --- a/backend/crates/gateway-admin/src/use_case/settings.rs +++ b/backend/crates/gateway-admin/src/use_case/settings.rs @@ -22,6 +22,13 @@ use super::{map_store_error, publish_committed}; /// API 消费的 Runtime settings 管理服务 #[async_trait] pub trait SettingsService: Send + Sync { + async fn preview_privacy_policy( + &self, + _request: gateway_core::settings::privacy::PrivacyPreviewRequest, + ) -> Result { + Err(AdminError::invalid("隐私预览不可用")) + } + async fn preview_pricing_sync( &self, ) -> Result; @@ -117,6 +124,21 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> #[async_trait] impl SettingsService for DefaultSettingsService { + async fn preview_privacy_policy( + &self, + request: gateway_core::settings::privacy::PrivacyPreviewRequest, + ) -> Result { + self.profile_provider("openai")? + .preview_privacy_policy(request) + .map_err(|error| { + AdminError::invalid(format!( + "第 {} 条规则:{}", + error.rule_index + 1, + error.reason + )) + }) + } + async fn preview_pricing_sync( &self, ) -> Result { @@ -342,6 +364,17 @@ impl SettingsService for DefaultSettingsService { mut command: ReplaceRuntimeSettings, ) -> Result { validate_settings(&command)?; + if command.values.codex_privacy_policy != Default::default() { + self.profile_provider("openai")? + .compile_privacy_policy(&command.values.codex_privacy_policy) + .map_err(|error| { + AdminError::invalid(format!( + "第 {} 条规则:{}", + error.rule_index + 1, + error.reason + )) + })?; + } if command .request_profile_updates .values() diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index 4dd3e0589..97e38a45f 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -1115,6 +1115,7 @@ impl SettingsStore for StaticSettingsStore { rotation_strategy: RotationStrategy::Smart, updated_at: Utc::now(), values: gateway_admin::model::settings::RuntimeSettingsValues { + codex_privacy_policy: Default::default(), request_location_enabled: false, request_location: Default::default(), refresh_margin_seconds: 300, diff --git a/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs b/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs index 851352d35..8d56f77b7 100644 --- a/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs +++ b/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs @@ -32,6 +32,7 @@ fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> Runti rotation_strategy: gateway_admin::model::settings::RotationStrategy::Smart, updated_at: Utc::now(), values: gateway_admin::model::settings::RuntimeSettingsValues { + codex_privacy_policy: Default::default(), request_location_enabled: false, request_location: Default::default(), refresh_margin_seconds: 300, diff --git a/backend/crates/gateway-admin/tests/use_case/observability.rs b/backend/crates/gateway-admin/tests/use_case/observability.rs index 29b6150dd..646d74c1e 100644 --- a/backend/crates/gateway-admin/tests/use_case/observability.rs +++ b/backend/crates/gateway-admin/tests/use_case/observability.rs @@ -988,6 +988,7 @@ impl SettingsStore for FixtureSettingsStore { rotation_strategy: RotationStrategy::Smart, updated_at: Utc::now(), values: gateway_admin::model::settings::RuntimeSettingsValues { + codex_privacy_policy: Default::default(), request_location_enabled: false, request_location: Default::default(), refresh_margin_seconds: 300, diff --git a/backend/crates/gateway-admin/tests/use_case/service_contract.rs b/backend/crates/gateway-admin/tests/use_case/service_contract.rs index 35cb471d7..426605239 100644 --- a/backend/crates/gateway-admin/tests/use_case/service_contract.rs +++ b/backend/crates/gateway-admin/tests/use_case/service_contract.rs @@ -57,6 +57,7 @@ impl VisitMut for WireTypes { let last = path.path.segments.last().unwrap().clone(); *ty = match last.ident.to_string().as_str() { "DateTime" | "Tz" | "TokenPrice" | "AdminApiKey" => parse_quote!(String), + "Value" => parse_quote!(serde_json::Value), "OpaqueProviderData" => parse_quote!(serde_json::Map), _ => Type::Path(syn::TypePath { attrs: std::mem::take(&mut path.attrs), @@ -79,6 +80,10 @@ fn contract_type(mut item: Item, name: &str) -> Item { | "ReplaceRuntimeSettings" | "AdminApiKeyMutation" | "RegeneratedAdminApiKey" + | "CodexPrivacyPolicy" + | "PrivacyRule" + | "PrivacyPreviewRequest" + | "PrivacyPreviewResult" ); ( &mut item.attrs, @@ -107,6 +112,13 @@ fn contract_type(mut item: Item, name: &str) -> Item { field.vis = parse_quote!(pub); } } + if let Item::Enum(item) = &mut item { + for variant in &mut item.variants { + variant + .attrs + .retain(|attr| !attr.path().is_ident("default")); + } + } WireTypes.visit_item_mut(&mut item); item } @@ -117,6 +129,19 @@ fn sdk_settings_contract_matches_host_declarations() { let mut types = Vec::new(); let mut replacement_fields = Vec::new(); for (path, names) in [ + ( + "../gateway-core/src/settings/privacy.rs", + &[ + "CodexPrivacyPolicy", + "PrivacyFailureMode", + "PrivacyScope", + "PrivacyAction", + "PrivacyRule", + "PrivacyRuleOutcome", + "PrivacyPreviewRequest", + "PrivacyPreviewResult", + ][..], + ), ( "src/model/settings.rs", &[ diff --git a/backend/crates/gateway-admin/tests/use_case/settings.rs b/backend/crates/gateway-admin/tests/use_case/settings.rs index 517ca0448..db30fdd77 100644 --- a/backend/crates/gateway-admin/tests/use_case/settings.rs +++ b/backend/crates/gateway-admin/tests/use_case/settings.rs @@ -280,6 +280,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() { model_mappings: Default::default(), rotation_strategy: RotationStrategy::Smart, values: gateway_admin::model::settings::RuntimeSettingsValues { + codex_privacy_policy: Default::default(), request_location_enabled: false, request_location: Default::default(), refresh_margin_seconds: 0, diff --git a/backend/crates/gateway-api/src/admin/settings.rs b/backend/crates/gateway-api/src/admin/settings.rs index b6e706a18..78ff8c370 100644 --- a/backend/crates/gateway-api/src/admin/settings.rs +++ b/backend/crates/gateway-api/src/admin/settings.rs @@ -36,6 +36,7 @@ pub type ProviderRequestProfileUpdates = #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RuntimeSettingsFields { + pub codex_privacy_policy: gateway_core::settings::privacy::CodexPrivacyPolicy, pub request_location_enabled: bool, pub request_location: gateway_core::account::RequestLocation, pub refresh_margin_seconds: u64, @@ -283,6 +284,10 @@ where "/api/admin/settings/client-profiles/{provider}/preview", post(preview_client_profile::), ) + .route( + "/api/admin/settings/privacy/preview", + post(preview_privacy::), + ) .route("/api/admin/settings/update", post(update_settings::)) .route( "/api/admin/settings/client-downloads/codex-desktop/windows", @@ -719,6 +724,7 @@ impl RuntimeSettingsFields { self, ) -> Result { let values = gateway_admin::model::settings::RuntimeSettingsValues { + codex_privacy_policy: self.codex_privacy_policy, request_location_enabled: self.request_location_enabled, request_location: self .request_location @@ -767,6 +773,7 @@ impl RuntimeSettingsFields { impl From for RuntimeSettingsFields { fn from(settings: gateway_admin::model::settings::RuntimeSettingsValues) -> Self { crate::admin::settings::RuntimeSettingsFields { + codex_privacy_policy: settings.codex_privacy_policy, request_location_enabled: settings.request_location_enabled, request_location: settings.request_location, refresh_margin_seconds: settings.refresh_margin_seconds, @@ -837,3 +844,23 @@ fn settings_validation_error(field: &'static str) -> WireValidationError { _ => "settings", }) } + +async fn preview_privacy( + State(state): State, + _auth: AdminAuth, + AdminJson(request): AdminJson, +) -> Result +where + S: SessionState + Send + Sync, +{ + let result = state + .admin_services() + .settings() + .preview_privacy_policy(request) + .await + .map_err(map_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(result), + )) +} diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index e451ecaae..5986fbca4 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -600,6 +600,7 @@ impl SettingsStore for MemorySettingsStore { rotation_strategy: command.rotation_strategy, updated_at: Utc::now(), values: gateway_admin::model::settings::RuntimeSettingsValues { + codex_privacy_policy: command.values.codex_privacy_policy, request_location_enabled: command.values.request_location_enabled, request_location: command.values.request_location, refresh_margin_seconds: command.values.refresh_margin_seconds, @@ -1667,6 +1668,7 @@ fn test_runtime_settings() -> RuntimeSettings { rotation_strategy: RotationStrategy::Smart, updated_at: Utc::now(), values: gateway_admin::model::settings::RuntimeSettingsValues { + codex_privacy_policy: Default::default(), request_location_enabled: false, request_location: Default::default(), refresh_margin_seconds: 3_600, diff --git a/backend/crates/gateway-api/tests/admin/settings.rs b/backend/crates/gateway-api/tests/admin/settings.rs index b14d8d93a..80cc6d41c 100644 --- a/backend/crates/gateway-api/tests/admin/settings.rs +++ b/backend/crates/gateway-api/tests/admin/settings.rs @@ -40,6 +40,7 @@ async fn response_json(response: axum::response::Response) -> Value { fn update_body() -> Value { json!({ "configRevision": 7, + "codexPrivacyPolicy": {"enabled":false,"onError":"skip_rule","rules":[]}, "requestLocationEnabled": false, "requestLocation": {"country":"US", "region":"Ohio", "city":"Piketon", "timezone":"America/New_York"}, "modelMappings": { @@ -78,6 +79,63 @@ fn update_body() -> Value { }) } +#[tokio::test] +async fn privacy_save_requires_provider_validation_and_preview_requires_admin_auth() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let mut body = update_body(); + let policy = json!({"enabled":true,"onError":"reject_request","rules":[{ + "id":"auth","name":"自定义认证头","enabled":true,"scope":"request_header", + "selector":"authorization","action":"remove_field","pattern":null, + "replacement":"","value":null,"replaceAll":true,"caseInsensitive":false,"multiLine":false + }]}); + body["codexPrivacyPolicy"] = policy.clone(); + let response = app(fixture.state()) + .oneshot(request( + Method::POST, + "/api/admin/settings/update", + Some(body), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let response = app(fixture.state()) + .oneshot(request(Method::GET, "/api/admin/settings", None)) + .await + .unwrap(); + assert_eq!( + response_json(response).await["data"]["codexPrivacyPolicy"], + json!({"enabled":false,"onError":"skip_rule","rules":[]}) + ); + let response = app(fixture.state()) + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/admin/settings/privacy/preview") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + json!({"policy":policy,"body":{},"headers":{},"turnMetadata":null}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn privacy_policy_is_required_and_rejects_unknown_contract_fields() { + let mut missing = update_body(); + missing + .as_object_mut() + .unwrap() + .remove("codexPrivacyPolicy"); + assert!(serde_json::from_value::(missing).is_err()); + let mut unknown = update_body(); + unknown["codexPrivacyPolicy"]["protectedFields"] = json!([]); + assert!(serde_json::from_value::(unknown).is_err()); +} + #[tokio::test] async fn smart_settings_round_trip_and_invalid_updates_leave_the_saved_value_intact() { let fixture = AdminTestFixture::new().await; @@ -223,6 +281,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() { .single() .expect("timestamp"), values: gateway_admin::model::settings::RuntimeSettingsValues { + codex_privacy_policy: Default::default(), request_location_enabled: false, request_location: Default::default(), refresh_margin_seconds: 1800, @@ -268,6 +327,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() { "providerRequestProfiles": {}, "openaiClientProfile": null, "xaiClientProfile": null, + "codexPrivacyPolicy": {"enabled":false,"onError":"skip_rule","rules":[]}, "requestLocationEnabled": false, "requestLocation": {"country":"US", "region":"Ohio", "city":"Piketon", "timezone":"America/New_York"}, "modelMappings": { @@ -348,6 +408,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() { .expect("fixture rotation strategy"), updated_at: chrono::Utc::now(), values: gateway_admin::model::settings::RuntimeSettingsValues { + codex_privacy_policy: Default::default(), request_location_enabled: false, request_location: Default::default(), refresh_margin_seconds: request.values.refresh_margin_seconds, diff --git a/backend/crates/gateway-core/src/engine/coordinator.rs b/backend/crates/gateway-core/src/engine/coordinator.rs index 1c56882b0..5f8c8b64e 100644 --- a/backend/crates/gateway-core/src/engine/coordinator.rs +++ b/backend/crates/gateway-core/src/engine/coordinator.rs @@ -1045,6 +1045,7 @@ where .with_fast_mode(self.plan.fast_mode()) .with_pricing(self.plan.pricing()) .with_request_location(self.plan.request_location().cloned()) + .with_privacy(self.plan.privacy()) .with_concurrency_wait_budget(self.concurrency_wait_budget.clone()) .with_connection_budget(self.connection_budget.clone()) .with_timing_started_at(self.observation.timing_started_at) diff --git a/backend/crates/gateway-core/src/engine/mod.rs b/backend/crates/gateway-core/src/engine/mod.rs index 64636af8f..e11608186 100644 --- a/backend/crates/gateway-core/src/engine/mod.rs +++ b/backend/crates/gateway-core/src/engine/mod.rs @@ -287,6 +287,7 @@ pub struct RequestAttemptContext { request_profile: Option, fast_mode: FastMode, request_location: Option, + privacy: Option>, request_id: ModelRequestId, client_api_key_ref: ClientApiKeyId, timing_started_at: Instant, @@ -350,6 +351,15 @@ impl RequestAttemptContext { self } + #[must_use] + pub fn with_privacy( + mut self, + privacy: Option>, + ) -> Self { + self.privacy = privacy; + self + } + #[must_use] pub fn with_request_location( mut self, @@ -369,6 +379,7 @@ impl RequestAttemptContext { pricing: Arc::default(), fast_mode: FastMode::Default, request_location: None, + privacy: None, timing_started_at: Instant::now(), trace: crate::diagnostics::TraceContext::default(), concurrency_wait_budget: crate::concurrency::ConcurrencyWaitBudget::default(), @@ -569,6 +580,11 @@ impl AttemptContext { self.request.fast_mode } + #[must_use] + pub fn privacy(&self) -> Option> { + self.request.privacy.clone() + } + #[must_use] pub const fn request_location(&self) -> Option<&crate::account::RequestLocation> { self.request.request_location.as_ref() diff --git a/backend/crates/gateway-core/src/engine/provider.rs b/backend/crates/gateway-core/src/engine/provider.rs index 3ce482cc1..a72371357 100644 --- a/backend/crates/gateway-core/src/engine/provider.rs +++ b/backend/crates/gateway-core/src/engine/provider.rs @@ -965,6 +965,20 @@ impl fmt::Debug for ProviderRequest { /// 新的 attempt 再次调用 #[async_trait] pub trait Provider: Send + Sync { + /// Provider 解释自己的隐私规则,Core 仅冻结编译结果 + fn compile_privacy_policy( + &self, + _policy: &crate::settings::privacy::CodexPrivacyPolicy, + ) -> Result< + Arc, + crate::settings::privacy::PrivacyError, + > { + Err(crate::settings::privacy::PrivacyError { + rule_index: 0, + reason: "Provider 不支持隐私策略", + }) + } + /// 从已冻结的配置解析请求身份;只读取本地发布资料,不执行网络请求 fn resolve_request_profile( &self, @@ -1197,3 +1211,31 @@ impl ProviderCatalogPort for ProviderRegistry { }) } } + +impl std::fmt::Debug for ProviderRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ProviderRegistry") + .field("providers", &self.providers.keys()) + .finish() + } +} + +impl crate::settings::privacy::PrivacyPolicyCompiler for ProviderRegistry { + fn compile( + &self, + policy: &crate::settings::privacy::CodexPrivacyPolicy, + ) -> Result< + Arc, + crate::settings::privacy::PrivacyError, + > { + self.providers + .iter() + .find(|(kind, _)| kind.as_str() == "openai") + .ok_or(crate::settings::privacy::PrivacyError { + rule_index: 0, + reason: "OpenAI Provider 不可用", + })? + .1 + .compile_privacy_policy(policy) + } +} diff --git a/backend/crates/gateway-core/src/lib.rs b/backend/crates/gateway-core/src/lib.rs index 7b206691f..fb62c1a4e 100644 --- a/backend/crates/gateway-core/src/lib.rs +++ b/backend/crates/gateway-core/src/lib.rs @@ -472,7 +472,8 @@ pub fn prepare_control_plane( providers: ProviderRegistry, extensions: Option>, ) -> CoreControlPlaneStartup { - let mut compiler = RuntimeSnapshotCompiler::new(ports.snapshots, Arc::new(providers)); + let mut compiler = RuntimeSnapshotCompiler::new(ports.snapshots, Arc::new(providers.clone())) + .with_privacy_compiler(Arc::new(providers)); if let Some(extensions) = extensions { compiler = compiler.with_extensions(extensions); } diff --git a/backend/crates/gateway-core/src/routing/mod.rs b/backend/crates/gateway-core/src/routing/mod.rs index 532b212b5..d81dbcdb5 100644 --- a/backend/crates/gateway-core/src/routing/mod.rs +++ b/backend/crates/gateway-core/src/routing/mod.rs @@ -563,6 +563,7 @@ impl ProviderCandidate { pub struct RoutingPlan { pricing: Arc, request_location: Option, + privacy: Option>, config_revision: ConfigRevision, account_selection_policy: AccountSelectionPolicy, operation: OperationKind, @@ -582,6 +583,12 @@ impl RoutingPlan { self.account_scope.fast_mode() } + /// 本次请求冻结的已编译规则,重试不读取新的全局策略 + #[must_use] + pub fn privacy(&self) -> Option> { + self.privacy.clone() + } + /// 本次请求冻结的全局位置,重试时沿用同一份配置 #[must_use] pub const fn request_location(&self) -> Option<&crate::account::RequestLocation> { diff --git a/backend/crates/gateway-core/src/routing/snapshot.rs b/backend/crates/gateway-core/src/routing/snapshot.rs index c5dd3fe50..daa89c320 100644 --- a/backend/crates/gateway-core/src/routing/snapshot.rs +++ b/backend/crates/gateway-core/src/routing/snapshot.rs @@ -219,6 +219,7 @@ pub enum RuntimeSnapshotCompileError { #[derive(Clone)] pub struct RuntimeSnapshotCompiler { store: Arc, + privacy_compiler: Option>, catalogs: Arc, extensions: Option>, } @@ -231,6 +232,7 @@ impl RuntimeSnapshotCompiler { ) -> Self { Self { store, + privacy_compiler: None, catalogs, extensions: None, } @@ -245,6 +247,15 @@ impl RuntimeSnapshotCompiler { self } + #[must_use] + pub fn with_privacy_compiler( + mut self, + compiler: Arc, + ) -> Self { + self.privacy_compiler = Some(compiler); + self + } + pub(crate) fn store(&self) -> Arc { Arc::clone(&self.store) } @@ -314,8 +325,14 @@ impl RuntimeSnapshotCompiler { let cached = previous.filter(|previous| { previous.extensions().map(|set| set.id()) == extensions.as_ref().map(|set| set.id()) }); - let snapshot = - compile_runtime_snapshot(facts, catalogs.as_ref(), provider_kinds, cached).await?; + let snapshot = compile_runtime_snapshot( + facts, + catalogs.as_ref(), + provider_kinds, + cached, + self.privacy_compiler.clone(), + ) + .await?; let observed_generations = catalogs.catalog_generations(); if catalog_generations == observed_generations { if extensions.is_some() @@ -348,6 +365,7 @@ async fn compile_runtime_snapshot( catalogs: &dyn ProviderCatalogPort, provider_kinds: Vec, previous: Option<&RuntimeSnapshot>, + privacy_compiler: Option>, ) -> Result { // 只有成功取得的完整目录能证明模型缺项;发现型目录和查询失败均交由上游验证 let mut provider_models = Vec::new(); @@ -504,12 +522,13 @@ async fn compile_runtime_snapshot( )); } - RuntimeSnapshot::new( + RuntimeSnapshot::new_with_privacy_compiler( facts.config_revision, facts.settings, provider_kinds, provider_models, client_policies, + privacy_compiler, ) .map_err(|_| RuntimeSnapshotCompileError::InvalidData) .map(|snapshot| { @@ -524,6 +543,7 @@ async fn compile_runtime_snapshot( #[derive(Debug, Clone)] pub struct RuntimeSnapshot { settings: Arc, + privacy_compiler: Option>, extensions: Option, revision: ConfigRevision, providers: Arc>, @@ -559,8 +579,12 @@ impl RuntimeSnapshot { &self, values: &SettingsValues, ) -> Result { - let settings = CompiledSettings::new(values.clone()) - .map_err(|_| RuntimeSnapshotCompileError::InvalidData)?; + let settings = CompiledSettings::new( + values.clone(), + self.privacy_compiler.as_deref(), + Some(&self.settings), + ) + .map_err(|_| RuntimeSnapshotCompileError::InvalidData)?; Ok(Self { settings: Arc::new(settings), ..self.clone() @@ -649,6 +673,24 @@ impl RuntimeSnapshot { providers: Vec, provider_models: Vec, client_policies: Vec, + ) -> Result { + Self::new_with_privacy_compiler( + revision, + values, + providers, + provider_models, + client_policies, + None, + ) + } + + pub fn new_with_privacy_compiler( + revision: ConfigRevision, + values: SettingsValues, + providers: Vec, + provider_models: Vec, + client_policies: Vec, + privacy_compiler: Option>, ) -> Result { let mut provider_set = BTreeSet::new(); for provider in providers { @@ -709,9 +751,11 @@ impl RuntimeSnapshot { } client_policy_map.retain(|_, policy| policy.enabled()); - let settings = CompiledSettings::new(values).map_err(|_| RoutingError::InvalidSettings)?; + let settings = CompiledSettings::new(values, privacy_compiler.as_deref(), None) + .map_err(|_| RoutingError::InvalidSettings)?; Ok(Self { settings: Arc::new(settings), + privacy_compiler, extensions: None, revision, providers: Arc::new(provider_set), @@ -1143,6 +1187,7 @@ impl RuntimeSnapshot { config_revision: self.revision, pricing: Arc::clone(&self.settings.values.pricing), request_location: self.settings.request_location.clone(), + privacy: self.settings.privacy.clone(), account_selection_policy: self.settings.account_selection_policy, operation: operation.kind(), max_attempts: NonZeroU32::new(super::MAX_REQUEST_ATTEMPTS) @@ -1223,6 +1268,7 @@ impl RuntimeSnapshot { config_revision: self.revision, pricing: Arc::clone(&self.settings.values.pricing), request_location: self.settings.request_location.clone(), + privacy: self.settings.privacy.clone(), account_selection_policy: self.settings.account_selection_policy, operation: operation.kind(), max_attempts: NonZeroU32::new(super::MAX_REQUEST_ATTEMPTS) diff --git a/backend/crates/gateway-core/src/settings/compiled.rs b/backend/crates/gateway-core/src/settings/compiled.rs index 92be6a5de..2721758a4 100644 --- a/backend/crates/gateway-core/src/settings/compiled.rs +++ b/backend/crates/gateway-core/src/settings/compiled.rs @@ -13,6 +13,7 @@ use crate::{ #[derive(Debug, Clone)] pub(crate) struct CompiledSettings { pub(crate) values: SettingsValues, + pub(crate) privacy: Option>, pub(crate) account_selection_policy: AccountSelectionPolicy, pub(crate) client_queue_policy: ConcurrencyQueuePolicy, pub(crate) responses_max_decompressed_body_bytes: std::num::NonZeroUsize, @@ -21,7 +22,34 @@ pub(crate) struct CompiledSettings { } impl CompiledSettings { - pub(crate) fn new(settings: SettingsValues) -> Result { + pub(crate) fn new( + settings: SettingsValues, + compiler: Option<&dyn super::privacy::PrivacyPolicyCompiler>, + previous: Option<&Self>, + ) -> Result { + let privacy = if let Some(previous) = previous.filter(|previous| { + previous.values.codex_privacy_policy == settings.codex_privacy_policy + }) { + previous.privacy.clone() + } else if settings.codex_privacy_policy == super::privacy::CodexPrivacyPolicy::default() { + None + } else { + Some( + compiler + .ok_or(InvalidSettings)? + .compile(&settings.codex_privacy_policy) + .map_err(|_| InvalidSettings)?, + ) + }; + // 关闭或没有启用规则时不进入出站管线,保留原始 JSON 字节和重复键语义 + let privacy = privacy.filter(|_| { + settings.codex_privacy_policy.enabled + && settings + .codex_privacy_policy + .rules + .iter() + .any(|rule| rule.enabled) + }); super::validate_request_limits( settings.max_waiting_per_key, settings.max_waiting_per_account, @@ -79,6 +107,7 @@ impl CompiledSettings { min_codex_client_versions, request_location, values: settings, + privacy, }) } } diff --git a/backend/crates/gateway-core/src/settings/mod.rs b/backend/crates/gateway-core/src/settings/mod.rs index 10adbfb08..9efc4fdd7 100644 --- a/backend/crates/gateway-core/src/settings/mod.rs +++ b/backend/crates/gateway-core/src/settings/mod.rs @@ -1,6 +1,7 @@ //! 运行设置的纯值、校验与编译结果 pub(crate) mod compiled; +pub mod privacy; mod values; pub use values::{ SettingsValues, client_min_versions, response_body_limit, validate_request_limits, diff --git a/backend/crates/gateway-core/src/settings/privacy.rs b/backend/crates/gateway-core/src/settings/privacy.rs new file mode 100644 index 000000000..8dbcb717d --- /dev/null +++ b/backend/crates/gateway-core/src/settings/privacy.rs @@ -0,0 +1,134 @@ +//! 隐私策略配置、编译端口与无敏感值的执行结果,协议解析由 Provider 拥有 + +use std::{fmt, sync::Arc}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CodexPrivacyPolicy { + pub enabled: bool, + pub on_error: PrivacyFailureMode, + pub rules: Vec, +} + +impl fmt::Debug for CodexPrivacyPolicy { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CodexPrivacyPolicy") + .field("enabled", &self.enabled) + .field("on_error", &self.on_error) + .field("rule_count", &self.rules.len()) + .finish() + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PrivacyFailureMode { + #[default] + SkipRule, + RejectRequest, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PrivacyScope { + TurnMetadata, + DesktopGitContext, + EnvironmentText, + RequestBody, + RequestHeader, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PrivacyAction { + RegexReplace, + SetValue, + RenameKey, + RemoveField, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PrivacyRule { + pub id: String, + pub name: String, + pub enabled: bool, + pub scope: PrivacyScope, + pub selector: String, + pub action: PrivacyAction, + /// 删除动作的 null 表示无条件删除,字符串模式只匹配目标字符串值 + pub pattern: Option, + pub replacement: String, + pub value: Value, + pub replace_all: bool, + pub case_insensitive: bool, + pub multi_line: bool, +} + +impl fmt::Debug for PrivacyRule { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PrivacyRule") + .field("scope", &self.scope) + .field("action", &self.action) + .field("enabled", &self.enabled) + .finish_non_exhaustive() + } +} + +/// 编译和执行失败仅携带规则序号与静态原因,不回显表达式或请求内容 +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize)] +#[serde(rename_all = "camelCase")] +#[error("privacy rule {rule_index}: {reason}")] +pub struct PrivacyError { + pub rule_index: usize, + pub reason: &'static str, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrivacyRuleOutcome { + pub rule_id: String, + pub matches: usize, + pub status: String, + pub reason: Option, +} + +/// 已编译策略随快照冻结,调用方提供独立的本次出站副本 +pub trait CompiledPrivacyPolicy: fmt::Debug + Send + Sync { + fn apply( + &self, + body: &mut Value, + headers: &mut http::HeaderMap, + turn_metadata: &mut Option, + cancelled: &dyn Fn() -> bool, + ) -> Result, PrivacyError>; +} + +pub trait PrivacyPolicyCompiler: fmt::Debug + Send + Sync { + fn compile( + &self, + policy: &CodexPrivacyPolicy, + ) -> Result, PrivacyError>; +} + +/// 预览只接受管理员主动提供的受限样本,不读取实际请求记录 +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PrivacyPreviewRequest { + pub policy: CodexPrivacyPolicy, + pub body: Value, + pub headers: std::collections::BTreeMap>, + pub turn_metadata: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrivacyPreviewResult { + pub body: Value, + pub headers: std::collections::BTreeMap>, + pub turn_metadata: Option, + pub outcomes: Vec, +} diff --git a/backend/crates/gateway-core/src/settings/values.rs b/backend/crates/gateway-core/src/settings/values.rs index a75c075c9..90a840f5b 100644 --- a/backend/crates/gateway-core/src/settings/values.rs +++ b/backend/crates/gateway-core/src/settings/values.rs @@ -10,6 +10,7 @@ use crate::identity::ProviderKind; pub struct SettingsValues { pub(crate) pricing: Arc, pub(crate) request_profiles: Arc>, + pub(crate) codex_privacy_policy: super::privacy::CodexPrivacyPolicy, pub(crate) request_location_enabled: bool, pub(crate) request_location: crate::account::RequestLocation, pub(crate) max_concurrent_per_account: u32, @@ -30,6 +31,12 @@ pub struct SettingsValues { } impl SettingsValues { + #[must_use] + pub fn with_codex_privacy_policy(mut self, policy: super::privacy::CodexPrivacyPolicy) -> Self { + self.codex_privacy_policy = policy; + self + } + #[must_use] pub const fn with_openai_session_affinity_ttl_hours(mut self, hours: u32) -> Self { self.openai_session_affinity_ttl_hours = hours; @@ -131,6 +138,7 @@ impl SettingsValues { Self { request_profiles: Arc::default(), pricing: Arc::default(), + codex_privacy_policy: Default::default(), request_location_enabled: false, request_location: crate::account::RequestLocation::default(), max_concurrent_per_account, diff --git a/backend/crates/gateway-core/tests/routing/mod.rs b/backend/crates/gateway-core/tests/routing/mod.rs index 92d21416c..86be922d4 100644 --- a/backend/crates/gateway-core/tests/routing/mod.rs +++ b/backend/crates/gateway-core/tests/routing/mod.rs @@ -18,6 +18,7 @@ use gateway_core::routing::{ UpstreamModelId, }; +mod privacy; mod request_settings; mod snapshot; diff --git a/backend/crates/gateway-core/tests/routing/privacy.rs b/backend/crates/gateway-core/tests/routing/privacy.rs new file mode 100644 index 000000000..51e9cd1f5 --- /dev/null +++ b/backend/crates/gateway-core/tests/routing/privacy.rs @@ -0,0 +1,125 @@ +//! 隐私策略在快照与请求计划中的冻结、复用及关闭行为 + +use super::*; +use gateway_core::settings::privacy::*; +use std::sync::atomic::{AtomicUsize, Ordering}; + +#[derive(Debug, Default)] +struct Compiler(AtomicUsize); + +#[derive(Debug)] +struct Compiled(serde_json::Value); + +impl PrivacyPolicyCompiler for Compiler { + fn compile( + &self, + policy: &CodexPrivacyPolicy, + ) -> Result, PrivacyError> { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(Arc::new(Compiled(policy.rules[0].value.clone()))) + } +} + +impl CompiledPrivacyPolicy for Compiled { + fn apply( + &self, + body: &mut serde_json::Value, + _: &mut http::HeaderMap, + _: &mut Option, + _: &dyn Fn() -> bool, + ) -> Result, PrivacyError> { + *body = self.0.clone(); + Ok(Vec::new()) + } +} + +fn policy(value: &str) -> CodexPrivacyPolicy { + CodexPrivacyPolicy { + enabled: true, + on_error: PrivacyFailureMode::RejectRequest, + rules: vec![PrivacyRule { + id: "rule".into(), + name: "规则".into(), + enabled: true, + scope: PrivacyScope::RequestBody, + selector: "$.metadata".into(), + action: PrivacyAction::SetValue, + pattern: None, + replacement: String::new(), + value: serde_json::json!(value), + replace_all: false, + case_insensitive: false, + multi_line: false, + }], + } +} + +fn compiled(snapshot: &RuntimeSnapshot) -> Option> { + snapshot + .plan_provider_endpoint( + &ProviderKind::new("openai").unwrap(), + None, + &image_operation(), + snapshot.all_account_scope(), + &RoutingContext::default(), + ) + .unwrap() + .privacy() +} + +#[test] +fn privacy_is_frozen_and_unrelated_request_settings_reuse_compiled_rules() { + let compiler = Arc::new(Compiler::default()); + let snapshot = RuntimeSnapshot::new_with_privacy_compiler( + ConfigRevision::new(1).unwrap(), + settings().with_codex_privacy_policy(policy("first")), + vec![ProviderKind::new("openai").unwrap()], + vec![], + vec![], + Some(compiler.clone()), + ) + .unwrap() + .with_account_directory(account_directory()); + let original = compiled(&snapshot).unwrap(); + let limits = snapshot + .with_settings(&snapshot.settings().clone().with_max_account_rotations(5)) + .unwrap(); + assert!(Arc::ptr_eq(&original, &compiled(&limits).unwrap())); + assert_eq!(compiler.0.load(Ordering::SeqCst), 1); + let changed = snapshot + .with_settings( + &snapshot + .settings() + .clone() + .with_codex_privacy_policy(policy("second")), + ) + .unwrap(); + let mut body = serde_json::Value::Null; + original + .apply(&mut body, &mut http::HeaderMap::new(), &mut None, &|| false) + .unwrap(); + assert_eq!(body, "first"); + compiled(&changed) + .unwrap() + .apply(&mut body, &mut http::HeaderMap::new(), &mut None, &|| false) + .unwrap(); + assert_eq!(body, "second"); + assert_eq!(compiler.0.load(Ordering::SeqCst), 2); + for disable_rule in [false, true] { + let mut policy = policy("unused"); + if disable_rule { + policy.rules[0].enabled = false; + } else { + policy.enabled = false; + } + let disabled = snapshot + .with_settings( + &snapshot + .settings() + .clone() + .with_codex_privacy_policy(policy), + ) + .unwrap(); + assert!(compiled(&disabled).is_none()); + } +} diff --git a/backend/crates/gateway-plugin/sdk/src/call/services/settings.rs b/backend/crates/gateway-plugin/sdk/src/call/services/settings.rs index bab9cdc92..600fb3c35 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/services/settings.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/services/settings.rs @@ -16,8 +16,80 @@ pub type RotationStrategy = String; pub type AccountAffinity = String; pub type PricingOverrides = BTreeMap>; #[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CodexPrivacyPolicy { + pub enabled: bool, + pub on_error: PrivacyFailureMode, + pub rules: Vec, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PrivacyFailureMode { + SkipRule, + RejectRequest, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PrivacyScope { + TurnMetadata, + DesktopGitContext, + EnvironmentText, + RequestBody, + RequestHeader, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PrivacyAction { + RegexReplace, + SetValue, + RenameKey, + RemoveField, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PrivacyRule { + pub id: String, + pub name: String, + pub enabled: bool, + pub scope: PrivacyScope, + pub selector: String, + pub action: PrivacyAction, + /// 删除动作的 null 表示无条件删除,字符串模式只匹配目标字符串值 + pub pattern: Option, + pub replacement: String, + pub value: serde_json::Value, + pub replace_all: bool, + pub case_insensitive: bool, + pub multi_line: bool, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrivacyRuleOutcome { + pub rule_id: String, + pub matches: usize, + pub status: String, + pub reason: Option, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PrivacyPreviewRequest { + pub policy: CodexPrivacyPolicy, + pub body: serde_json::Value, + pub headers: BTreeMap>, + pub turn_metadata: Option, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrivacyPreviewResult { + pub body: serde_json::Value, + pub headers: BTreeMap>, + pub turn_metadata: Option, + pub outcomes: Vec, +} +#[derive(Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RuntimeSettings { + pub codex_privacy_policy: CodexPrivacyPolicy, pub request_location_enabled: bool, pub request_location: RequestLocation, pub refresh_margin_seconds: u64, @@ -57,6 +129,7 @@ pub struct RuntimeSettings { #[derive(Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ReplaceRuntimeSettings { + pub codex_privacy_policy: CodexPrivacyPolicy, pub request_location_enabled: bool, pub request_location: RequestLocation, pub refresh_margin_seconds: u64, @@ -190,6 +263,12 @@ impl Operation for Load { type Input = (); type Output = RuntimeSettings; } +pub struct PreviewPrivacyPolicy; +impl Operation for PreviewPrivacyPolicy { + const NAME: &'static str = "settings.preview_privacy_policy"; + type Input = PrivacyPreviewRequest; + type Output = PrivacyPreviewResult; +} pub struct Replace; impl Operation for Replace { const NAME: &'static str = "settings.replace"; @@ -253,6 +332,7 @@ impl Operation for PreviewClientProfile { impl From for ReplaceRuntimeSettings { fn from(settings: RuntimeSettings) -> Self { Self { + codex_privacy_policy: settings.codex_privacy_policy, request_location_enabled: settings.request_location_enabled, request_location: settings.request_location, refresh_margin_seconds: settings.refresh_margin_seconds, diff --git a/backend/crates/gateway-store/src/admin_adapter.rs b/backend/crates/gateway-store/src/admin_adapter.rs index 140127fed..0d4322a06 100644 --- a/backend/crates/gateway-store/src/admin_adapter.rs +++ b/backend/crates/gateway-store/src/admin_adapter.rs @@ -96,6 +96,7 @@ impl SettingsStore for AdminSettingsStoreAdapter { "provider_request_profiles_json".to_owned(), "request_location_enabled".to_owned(), "request_location_json".to_owned(), + "codex_privacy_policy_json".to_owned(), "model_mappings_json".to_owned(), "refresh_margin_seconds".to_owned(), "refresh_concurrency".to_owned(), diff --git a/backend/crates/gateway-store/src/postgres/runtime_settings.rs b/backend/crates/gateway-store/src/postgres/runtime_settings.rs index d4114d7b9..46ed94781 100644 --- a/backend/crates/gateway-store/src/postgres/runtime_settings.rs +++ b/backend/crates/gateway-store/src/postgres/runtime_settings.rs @@ -150,7 +150,7 @@ impl RuntimeSettingsRepository for PgRuntimeSettingsRepository { pub(crate) async fn load_runtime_settings_from_pool(pool: &PgPool) -> StoreResult { let row = sqlx::query_as::<_, RuntimeSettingsRow>( - "select provider_request_profiles_json, config_revision, admin_api_key, refresh_margin_seconds, request_location_json, request_location_enabled, + "select provider_request_profiles_json, config_revision, admin_api_key, refresh_margin_seconds, request_location_json, request_location_enabled, codex_privacy_policy_json, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, smart_scheduling_json, model_mappings_json, usage_retention_days, ops_event_retention_days, audit_retention_days, min_codex_desktop_version, @@ -328,7 +328,7 @@ pub(crate) async fn load_runtime_settings_in_transaction( transaction: &mut Transaction<'_, Postgres>, ) -> StoreResult { let row = sqlx::query_as::<_, RuntimeSettingsRow>( - "select provider_request_profiles_json, config_revision, admin_api_key, refresh_margin_seconds, request_location_json, request_location_enabled, + "select provider_request_profiles_json, config_revision, admin_api_key, refresh_margin_seconds, request_location_json, request_location_enabled, codex_privacy_policy_json, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, smart_scheduling_json, model_mappings_json, usage_retention_days, ops_event_retention_days, audit_retention_days, min_codex_desktop_version, @@ -412,6 +412,7 @@ pub(crate) async fn update_runtime_settings_in_transaction( openai_account_affinity = $32, max_account_rotations = $33, openai_session_affinity_ttl_hours = $34, + codex_privacy_policy_json = $35, updated_at = now() where id = 1 returning config_revision", @@ -461,6 +462,7 @@ pub(crate) async fn update_runtime_settings_in_transaction( .bind(update.values.openai_account_affinity.as_str()) .bind(i64::from(update.values.max_account_rotations)) .bind(i64::from(update.values.openai_session_affinity_ttl_hours)) + .bind(sqlx::types::Json(&update.values.codex_privacy_policy)) .fetch_optional(&mut **transaction) .await .map_err(|source| postgres_unavailable("update runtime settings in transaction", source))? @@ -523,6 +525,8 @@ struct RuntimeSettingsRow { request_interval_ms: i64, smart_scheduling_json: sqlx::types::Json, rotation_strategy: String, + codex_privacy_policy_json: + sqlx::types::Json, request_location_enabled: bool, request_location_json: sqlx::types::Json, model_mappings_json: sqlx::types::Json>, @@ -579,6 +583,7 @@ fn runtime_settings_from_row(row: RuntimeSettingsRow) -> StoreResult, + codex_privacy_policy_json: + sqlx::types::Json, request_location_enabled: bool, responses_max_decompressed_body_bytes: i64, provider_request_profiles_json: @@ -236,7 +238,7 @@ async fn load_settings( transaction: &mut Transaction<'_, Postgres>, ) -> StoreResult<(Revision, SettingsValues)> { let row = sqlx::query_as::<_, SnapshotSettingsRow>( - "select config_revision, max_concurrent_per_account, request_interval_ms, rotation_strategy, smart_scheduling_json, model_mappings_json, min_codex_desktop_version, min_codex_cli_version, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, openai_guardian_reserved_concurrency, openai_account_affinity, max_account_rotations, openai_session_affinity_ttl_hours, request_location_json, request_location_enabled, responses_max_decompressed_body_bytes, provider_request_profiles_json, pricing_overrides_json, pricing_synced_json from runtime_settings where id = 1", + "select config_revision, max_concurrent_per_account, request_interval_ms, rotation_strategy, smart_scheduling_json, model_mappings_json, min_codex_desktop_version, min_codex_cli_version, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, openai_guardian_reserved_concurrency, openai_account_affinity, max_account_rotations, openai_session_affinity_ttl_hours, request_location_json, request_location_enabled, codex_privacy_policy_json, responses_max_decompressed_body_bytes, provider_request_profiles_json, pricing_overrides_json, pricing_synced_json from runtime_settings where id = 1", ) .fetch_optional(&mut **transaction) .await @@ -281,6 +283,7 @@ async fn load_settings( ) }) .with_request_location(row.request_location_json.0, row.request_location_enabled) + .with_codex_privacy_policy(row.codex_privacy_policy_json.0) .with_concurrency_queues( to_u32(row.max_waiting_per_key)?, to_u32(row.max_waiting_per_account)?, diff --git a/backend/crates/gateway-store/tests/postgres/runtime_settings.rs b/backend/crates/gateway-store/tests/postgres/runtime_settings.rs index 8f2adee0b..59995942a 100644 --- a/backend/crates/gateway-store/tests/postgres/runtime_settings.rs +++ b/backend/crates/gateway-store/tests/postgres/runtime_settings.rs @@ -18,6 +18,7 @@ pub(super) fn settings_with_margin(refresh_margin_seconds: u64) -> RuntimeSettin ("grok-latest".to_owned(), "grok-4.5".to_owned()), ]), values: gateway_admin::model::settings::RuntimeSettingsValues { + codex_privacy_policy: Default::default(), request_location_enabled: false, request_location: Default::default(), refresh_margin_seconds, @@ -58,6 +59,59 @@ fn runtime_settings_keep_account_rotation_global() { assert!(settings.validate().is_ok()); } +#[tokio::test] +async fn privacy_policy_upgrade_defaults_off_and_round_trips_in_snapshot() { + use gateway_core::settings::privacy::*; + use gateway_store::postgres::{PgRuntimeSnapshotRepository, RuntimeSnapshotRepository}; + let Some(database) = TestDatabase::create_through("privacy_policy", 25).await else { + return; + }; + super::TEST_MIGRATOR.run(&database.pool).await.unwrap(); + let repository = PgRuntimeSettingsRepository::new(database.pool.clone()); + let before = repository.load_runtime_settings().await.unwrap(); + assert_eq!( + before.values.codex_privacy_policy, + CodexPrivacyPolicy::default() + ); + let mut update = settings_with_margin(300); + update.values.codex_privacy_policy = CodexPrivacyPolicy { + enabled: true, + on_error: PrivacyFailureMode::RejectRequest, + rules: vec![PrivacyRule { + id: "remove-auth".into(), + name: "配置者控制".into(), + enabled: true, + scope: PrivacyScope::RequestHeader, + selector: "authorization".into(), + action: PrivacyAction::RemoveField, + pattern: None, + replacement: String::new(), + value: serde_json::Value::Null, + replace_all: true, + case_insensitive: false, + multi_line: false, + }], + }; + let expected = update.values.codex_privacy_policy.clone(); + repository.update_runtime_settings(update).await.unwrap(); + let after = repository.load_runtime_settings().await.unwrap(); + assert_eq!(after.values.codex_privacy_policy, expected); + assert!(after.config_revision > before.config_revision); + let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone()) + .load_runtime_snapshot() + .await + .unwrap(); + assert_eq!( + serde_json::to_value(snapshot.settings).unwrap()["codex_privacy_policy"], + serde_json::to_value(expected).unwrap() + ); + assert_eq!( + before.values.codex_privacy_policy, + CodexPrivacyPolicy::default() + ); + database.close().await; +} + #[tokio::test] async fn smart_settings_upgrade_preserves_selection_and_publishes_custom_config() { use gateway_core::account::SmartSchedulingConfig; @@ -1187,9 +1241,15 @@ async fn preferred_affinity_migration_defaults_to_strict_and_preserves_saved_mod .await .unwrap(); let repository = PgRuntimeSettingsRepository::new(database.pool.clone()); - let mut update = settings_with_margin(3600); - update.values.openai_account_affinity = mode; - repository.update_runtime_settings(update).await.unwrap(); + // 升级前只写当时已有列,现行 Repository 依赖完整迁移后的 schema + sqlx::query("update runtime_settings set openai_account_affinity = $1, config_revision = config_revision + 1, updated_at = now() where id = 1") + .bind(match mode { + AccountAffinity::Relaxed => "relaxed", + _ => "strict", + }) + .execute(&database.pool) + .await + .unwrap(); super::TEST_MIGRATOR.run(&database.pool).await.unwrap(); assert_eq!( repository diff --git a/backend/crates/providers/openai/Cargo.toml b/backend/crates/providers/openai/Cargo.toml index 5ae2d6ac6..aac599f26 100644 --- a/backend/crates/providers/openai/Cargo.toml +++ b/backend/crates/providers/openai/Cargo.toml @@ -35,6 +35,7 @@ hyper-util = { version = "0.1.20", features = ["client-legacy", "http1", "tokio" # vendored OpenSSL 固定 Linux 发布版的 TLS 画像,且不启用压缩特性。 native-tls = "=0.2.14" reqwest = { version = "=0.12.28", features = ["json", "stream", "cookies", "rustls-tls-native-roots", "socks"] } +regex = "1.13.0" roxmltree = "0.21.1" semver = "1.0.27" # 与官方 rustls 路径一致:aws-lc 支持 P-521,优先提供 X25519MLKEM768。 diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index 3291c843e..55b8226b2 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -164,6 +164,26 @@ impl OpenAiAdminProvider { #[async_trait] impl ProviderAdmin for OpenAiAdminProvider { + fn compile_privacy_policy( + &self, + policy: &gateway_core::settings::privacy::CodexPrivacyPolicy, + ) -> Result< + Arc, + gateway_core::settings::privacy::PrivacyError, + > { + crate::transport::privacy::compile(policy) + } + + fn preview_privacy_policy( + &self, + request: gateway_core::settings::privacy::PrivacyPreviewRequest, + ) -> Result< + gateway_core::settings::privacy::PrivacyPreviewResult, + gateway_core::settings::privacy::PrivacyError, + > { + crate::transport::privacy::preview(request) + } + fn account_capabilities( &self, _account_id: &ProviderAccountId, diff --git a/backend/crates/providers/openai/src/provider/execution.rs b/backend/crates/providers/openai/src/provider/execution.rs index 42414604a..2675e9777 100644 --- a/backend/crates/providers/openai/src/provider/execution.rs +++ b/backend/crates/providers/openai/src/provider/execution.rs @@ -343,7 +343,8 @@ impl CodexProvider { .for_account(lease.account()) .map_err(|error| map_client_error(error, UpstreamSendState::NotSent, false).error)? .with_responses_api_base_url(lease.authentication().responses_api_base_url()) - .with_middleware_headers(middleware_headers), + .with_middleware_headers(middleware_headers) + .with_privacy(context.privacy(), context.cancellation().clone()), response_origin: request.response_origin, endpoint_path: request.endpoint_path, body: request.body, diff --git a/backend/crates/providers/openai/src/provider/failure.rs b/backend/crates/providers/openai/src/provider/failure.rs index dcb622a3d..1b83c312c 100644 --- a/backend/crates/providers/openai/src/provider/failure.rs +++ b/backend/crates/providers/openai/src/provider/failure.rs @@ -890,6 +890,13 @@ pub(super) fn map_client_error( let connect_retry = !local_connection_capacity && matches!(&error, CodexClientError::Http(error) if transient_http_connect(error)); let mut failure = match &error { + CodexClientError::Privacy(_) => MappedProviderFailure::plain( + provider_error( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + ) + .with_retry_prohibited(), + ), CodexClientError::ConnectionBudgetExhausted => MappedProviderFailure::plain( provider_error(ProviderErrorKind::Timeout, UpstreamSendState::NotSent) .with_connection_retry(gateway_core::engine::AttemptTransport::Fallback), @@ -1115,6 +1122,11 @@ fn client_diagnostic(error: &CodexClientError) -> Option { "invalid_sse", "OpenAI HTTP returned an invalid Responses event stream".to_owned(), ), + CodexClientError::Privacy(error) => ( + "prepare", + "privacy_policy_rejected", + format!("Privacy rule {}: {}", error.rule_index + 1, error.reason), + ), CodexClientError::InvalidHeaderName(_) => ( "prepare", "invalid_header_name", diff --git a/backend/crates/providers/openai/src/provider/mod.rs b/backend/crates/providers/openai/src/provider/mod.rs index ddde47be2..48e1a7154 100644 --- a/backend/crates/providers/openai/src/provider/mod.rs +++ b/backend/crates/providers/openai/src/provider/mod.rs @@ -376,6 +376,16 @@ impl fmt::Debug for CodexProvider { #[async_trait] impl Provider for CodexProvider { + fn compile_privacy_policy( + &self, + policy: &gateway_core::settings::privacy::CodexPrivacyPolicy, + ) -> Result< + Arc, + gateway_core::settings::privacy::PrivacyError, + > { + crate::transport::privacy::compile(policy) + } + fn resolve_request_profile( &self, configuration: &gateway_core::account::OpaqueProviderData, @@ -1016,7 +1026,8 @@ impl CodexProvider { .with_responses_api_base_url(lease.authentication().responses_api_base_url()) .with_connection_budget(context.connection_budget().clone()) .with_response_control(context.response_control().cloned()) - .with_middleware_headers(middleware_headers), + .with_middleware_headers(middleware_headers) + .with_privacy(context.privacy(), context.cancellation().clone()), response_origin: self.responses_url.clone(), request: upstream_request, upstream_model, diff --git a/backend/crates/providers/openai/src/transport/client.rs b/backend/crates/providers/openai/src/transport/client.rs index bac975b64..3aea968ee 100644 --- a/backend/crates/providers/openai/src/transport/client.rs +++ b/backend/crates/providers/openai/src/transport/client.rs @@ -195,6 +195,8 @@ impl fmt::Debug for CodexClientVisibleUpstreamResponse { /// Codex 上游 HTTP 客户端错误 #[derive(Error)] pub enum CodexClientError { + #[error("privacy policy rejected the request: {0}")] + Privacy(#[from] gateway_core::settings::privacy::PrivacyError), #[error("connection recovery budget exhausted")] ConnectionBudgetExhausted, /// Reqwest 传输失败 @@ -279,6 +281,7 @@ impl fmt::Debug for CodexClientError { Self::ConnectionBudgetExhausted => { formatter.write_str("CodexClientError::ConnectionBudgetExhausted") } + Self::Privacy(error) => std::fmt::Debug::fmt(error, formatter), Self::Http(_) => formatter.write_str("CodexClientError::Http([REDACTED])"), Self::HttpJson(_) => formatter.write_str("CodexClientError::HttpJson([REDACTED])"), Self::ErrorBodyRead { @@ -345,7 +348,8 @@ impl CodexClientError { Self::Upstream { transport, .. } | Self::ErrorBodyRead { transport, .. } => { Some(*transport) } - Self::ConnectionBudgetExhausted + Self::Privacy(_) + | Self::ConnectionBudgetExhausted | Self::CustomCa(_) | Self::InvalidHeaderName(_) | Self::InvalidHeaderValue(_) @@ -677,6 +681,10 @@ impl OpenAiUpstreamProtocol { /// Codex HTTP/SSE 上游客户端 #[derive(Clone)] pub struct CodexBackendClient { + pub(super) privacy: Option<( + Arc, + gateway_core::lifecycle::CancellationToken, + )>, pub(super) timezone: gateway_core::time::DeploymentTimeZone, pub(super) response_control: Option, pub(super) connection_budget: Option, diff --git a/backend/crates/providers/openai/src/transport/client_json.rs b/backend/crates/providers/openai/src/transport/client_json.rs index be5ca252a..5ae6a2c8f 100644 --- a/backend/crates/providers/openai/src/transport/client_json.rs +++ b/backend/crates/providers/openai/src/transport/client_json.rs @@ -16,7 +16,7 @@ use super::{ }; impl CodexBackendClient { - /// 向固定 Provider 端点发送一次原始 JSON;请求与成功响应正文均不经过 serde + /// 向固定 Provider 端点发送 JSON,仅启用隐私规则时解码请求正文 pub(crate) async fn post_raw_json( &self, endpoint_path: &'static str, @@ -49,6 +49,21 @@ impl CodexBackendClient { context.turn_metadata, ); self.append_middleware_headers(&mut headers)?; + let body = if self.privacy.is_some() { + let mut value: serde_json::Value = + serde_json::from_slice(&body).map_err(CodexClientError::RequestBodyEncode)?; + let original = value.clone(); + self.apply_privacy(&mut value, &mut headers)?; + if value == original { + body + } else { + bytes::Bytes::from( + serde_json::to_vec(&value).map_err(CodexClientError::RequestBodyEncode)?, + ) + } + } else { + body + }; let trace = context .trace diff --git a/backend/crates/providers/openai/src/transport/client_sse.rs b/backend/crates/providers/openai/src/transport/client_sse.rs index 92c8163e3..d54703608 100644 --- a/backend/crates/providers/openai/src/transport/client_sse.rs +++ b/backend/crates/providers/openai/src/transport/client_sse.rs @@ -66,6 +66,7 @@ impl CodexBackendClient { ) -> Self { let base_url = base_url.into().trim_end_matches('/').to_string(); Self { + privacy: None, timezone: Default::default(), connection_budget: None, response_control: None, @@ -113,7 +114,7 @@ impl CodexBackendClient { upstream_request: &CodexResponsesRequest, context: CodexRequestContext<'_>, ) -> CodexClientResult { - let headers = self.request_headers_for_http_response(upstream_request, context)?; + let mut headers = self.request_headers_for_http_response(upstream_request, context)?; let headers_started_at = Instant::now(); // OAuth 请求遵循 Codex 压缩合同;API Key 上游使用普通 JSON // Codex 上游只交付 SSE;即使下游请求 `stream: false`,也要上游流式执行, @@ -122,6 +123,11 @@ impl CodexBackendClient { // 直接透传给 Codex,否则上游会以 400 拒绝非流式请求 let mut upstream_body = upstream_request.body().clone(); upstream_body.insert("stream".to_owned(), serde_json::Value::Bool(true)); + let mut upstream_body = serde_json::Value::Object(upstream_body); + if self.protocol == OpenAiUpstreamProtocol::Codex { + headers.insert(CONTENT_ENCODING, HeaderValue::from_static("zstd")); + } + self.apply_privacy(&mut upstream_body, &mut headers)?; let body = serde_json::to_vec(&upstream_body).map_err(CodexClientError::RequestBodyEncode)?; let endpoint = endpoint_url(&self.base_url, self.protocol.responses_path()); @@ -141,9 +147,8 @@ impl CodexBackendClient { ); trace.capture("upstream.request.body", &body); let client = self.http_opening_client()?; - let mut outbound = client.post(endpoint).headers(headers); + let outbound = client.post(endpoint).headers(headers); let body = if self.protocol == OpenAiUpstreamProtocol::Codex { - outbound = outbound.header(CONTENT_ENCODING, HeaderValue::from_static("zstd")); zstd::stream::encode_all(std::io::Cursor::new(body), 3) .map_err(CodexClientError::RequestCompression)? } else { @@ -278,8 +283,18 @@ impl CodexBackendClient { }); } - let websocket_request = websocket_upstream_request(request); - let headers = self.request_headers_for_websocket_response(&websocket_request, context)?; + let mut websocket_request = websocket_upstream_request(request); + let mut headers = + self.request_headers_for_websocket_response(&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()); + 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 { + rule_index: 0, + reason: "WS 请求正文必须是对象", + }) + })?; let mut websocket_create = CodexWebSocketConnection::responses_create_request_for_path( &self.base_url, self.protocol.responses_path(), @@ -313,7 +328,11 @@ impl CodexBackendClient { tracing::warn!(error = %error, "Failed to write Codex WebSocket audit artifact"); } } - let connection_profile = websocket_connection_profile(&headers, &self.middleware_headers); + let connection_profile = websocket_connection_profile( + &headers, + &self.middleware_headers, + original_headers.as_ref(), + ); let pool_key = self.websocket_pool_key(request, context, pool_account_id, &connection_profile); let pool_log_context = pool_key.as_ref().map(WebSocketPoolLogContext::from_key); @@ -664,6 +683,7 @@ async fn read_model_catalog_body(response: ReqwestResponse) -> CodexClientResult fn websocket_connection_profile( headers: &HeaderMap, middleware_headers: &[gateway_core::engine::middleware::MiddlewareHeader], + original_headers: Option<&HeaderMap>, ) -> String { let mut profile = [ "originator", @@ -693,6 +713,38 @@ fn websocket_connection_profile( profile.push('\0'); profile.push_str(&hex::encode(digest.finalize())); } + if let Some(original) = original_headers { + use sha2::{Digest, Sha256}; + let names: std::collections::BTreeSet<_> = original + .keys() + .chain(headers.keys()) + .map(|name| name.as_str()) + .collect(); + let mut digest = Sha256::new(); + let mut changed = false; + for name in names { + if original + .get_all(name) + .iter() + .eq(headers.get_all(name).iter()) + { + continue; + } + changed = true; + digest.update(name.len().to_le_bytes()); + digest.update(name.as_bytes()); + digest.update(headers.get_all(name).iter().count().to_le_bytes()); + for value in headers.get_all(name) { + digest.update(value.len().to_le_bytes()); + digest.update(value.as_bytes()); + } + } + // 改写后的握手头必须参与池身份,避免复用仍携带旧隐私值的连接 + if changed { + profile.push('\0'); + profile.push_str(&hex::encode(digest.finalize())); + } + } profile } diff --git a/backend/crates/providers/openai/src/transport/mod.rs b/backend/crates/providers/openai/src/transport/mod.rs index cfe57ea97..c23657cc6 100644 --- a/backend/crates/providers/openai/src/transport/mod.rs +++ b/backend/crates/providers/openai/src/transport/mod.rs @@ -72,3 +72,5 @@ pub use self::{ CodexWebSocketPool, CodexWebSocketPoolConfig, CodexWebSocketPoolKey, WebSocketPoolDecision, }, }; + +pub mod privacy; diff --git a/backend/crates/providers/openai/src/transport/privacy/carrier.rs b/backend/crates/providers/openai/src/transport/privacy/carrier.rs new file mode 100644 index 000000000..c42d8021e --- /dev/null +++ b/backend/crates/providers/openai/src/transport/privacy/carrier.rs @@ -0,0 +1,288 @@ +//! 对独立 metadata 副本及已识别自动上下文进行解码、改写与原位写回 + +use gateway_core::settings::privacy::PrivacyScope; +use reqwest::header::{HeaderMap, HeaderValue}; +use serde_json::Value; + +use super::{Budget, rule::CompiledRule}; + +const METADATA_KEYS: &[&str] = &["turnMetadata", "turn_metadata", "x-codex-turn-metadata"]; + +pub(super) fn apply( + rule: &CompiledRule, + body: &mut Value, + headers: &mut HeaderMap, + turn_metadata: &mut Option, + budget: &mut Budget<'_>, +) -> Result { + match rule.rule.scope { + PrivacyScope::TurnMetadata => { + let mut count = 0; + if let Some(raw) = turn_metadata { + count += metadata(rule, raw, budget)?; + } + for (name, value) in headers.iter_mut() { + if name == "x-codex-turn-metadata" { + let mut raw = std::str::from_utf8(value.as_bytes()) + .map_err(|_| "metadata 请求头编码无效")? + .to_owned(); + count += metadata(rule, &mut raw, budget)?; + *value = HeaderValue::from_str(&raw).map_err(|_| "metadata 请求头编码无效")?; + } + } + count += metadata_fields(rule, body, budget)?; + if let Some(client) = body.get_mut("client_metadata") { + count += metadata_fields(rule, client, budget)?; + } + Ok(count) + } + PrivacyScope::DesktopGitContext | PrivacyScope::EnvironmentText => { + contexts(rule, body, budget) + } + PrivacyScope::RequestBody => rule.apply(body, budget), + PrivacyScope::RequestHeader => { + let name = reqwest::header::HeaderName::from_bytes(rule.rule.selector.as_bytes()) + .map_err(|_| "请求头名称无效")?; + let mut items = headers + .get_all(&name) + .iter() + .map(|value| { + std::str::from_utf8(value.as_bytes()) + .map(|s| Value::String(s.to_owned())) + .map_err(|_| "目标请求头不是文本") + }) + .collect::, _>>()?; + if items.is_empty() { + return Ok(0); + } + let mut count = 0; + for index in (0..items.len()).rev() { + // 复用同一目标执行器,删除根标记只在请求头承载点解释 + let removed = rule.apply_value(&mut items[index], budget, &mut count)?; + if removed { + items.remove(index); + } + } + if count == 0 { + return Ok(0); + } + headers.remove(&name); + for value in items { + let value = HeaderValue::from_str(value.as_str().ok_or("请求头只能保存字符串")?) + .map_err(|_| "替换结果不能作为请求头")?; + // WS opening 会忽略不能转为 ASCII 文本的头,预览应提前暴露这个失败 + value + .to_str() + .map_err(|_| "替换结果必须是 ASCII 请求头值")?; + headers.append(name.clone(), value); + } + Ok(count) + } + } +} + +fn metadata_fields( + rule: &CompiledRule, + object: &mut Value, + budget: &mut Budget<'_>, +) -> Result { + let mut count = 0; + for key in METADATA_KEYS { + if let Some(value) = object.get_mut(*key) { + match value { + Value::String(raw) => count += metadata(rule, raw, budget)?, + Value::Object(_) => count += rule.apply(value, budget)?, + _ => return Err("metadata 必须是 JSON 对象或编码后的字符串"), + } + } + } + Ok(count) +} + +fn metadata( + rule: &CompiledRule, + raw: &mut String, + budget: &mut Budget<'_>, +) -> Result { + budget.text(raw)?; + let mut value: Value = serde_json::from_str(raw).map_err(|_| "metadata JSON 无法解析")?; + if !value.is_object() { + return Err("metadata 必须是对象"); + } + let count = rule.apply(&mut value, budget)?; + if count > 0 { + let encoded = crate::transport::request::serialize_ascii_turn_metadata(&value) + .ok_or("metadata 无法编码")?; + if encoded.len() > super::MAX_VALUE_BYTES { + return Err("metadata 编码结果超出大小限制"); + } + *raw = encoded; + } + Ok(count) +} + +fn contexts( + rule: &CompiledRule, + body: &mut Value, + budget: &mut Budget<'_>, +) -> Result { + let Some(items) = body.get_mut("input").and_then(Value::as_array_mut) else { + return Ok(0); + }; + let mut count = 0; + for item in items { + budget.step()?; + let role = item + .get("role") + .and_then(Value::as_str) + .unwrap_or("") + .to_owned(); + let desktop = rule.rule.scope == PrivacyScope::DesktopGitContext; + if !(role == "user" || (!desktop && role == "developer")) { + continue; + } + let (kind, tag) = if desktop { + ( + "additional_content.codex_git_state", + "external_codex_git_state", + ) + } else if role == "developer" { + ( + "additional_content.codex_apps_client_time_context", + "codex_apps_client_time_context", + ) + } else { + ("environments.environment_context", "environment_context") + }; + let kinds = item + .pointer("/internal_chat_message_metadata_passthrough/content_item_kinds") + .cloned(); + let Some(parts) = item.get_mut("content").and_then(Value::as_array_mut) else { + continue; + }; + for (index, part) in parts.iter_mut().enumerate() { + if let Some(kinds) = &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(Value::String(text)) = part.get_mut("text") else { + continue; + }; + let full = text.trim().starts_with(&format!("<{tag}>")) + && text.trim().ends_with(&format!("")); + if !full && kinds.is_none() { + continue; + } + if !full { + return Err("已识别上下文不完整,可能已被截断"); + } + budget.text(text)?; + count += if desktop { + git(rule, text, budget)? + } else { + environment(rule, text, budget)? + }; + } + } + Ok(count) +} + +fn git( + rule: &CompiledRule, + 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: &CompiledRule, + text: &mut String, + budget: &mut Budget<'_>, +) -> Result { + let before = xml_shape(text, budget)?; + let mut value = Value::String(text.clone()); + let count = rule.apply(&mut value, budget)?; + let result = value.as_str().ok_or("环境上下文必须是文本")?; + if xml_shape(result, budget)? != before { + return Err("替换不能改变环境上下文包裹结构"); + } + *text = result.to_owned(); + 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(); + for node in document.descendants().filter(|node| node.is_element()) { + budget.step()?; + let depth = node.ancestors().take(65).count(); + if depth > 64 { + return Err("上下文 XML 嵌套超过 64 层"); + } + shape.push((node.tag_name().name().to_owned(), depth)); + } + Ok(shape) +} diff --git a/backend/crates/providers/openai/src/transport/privacy/mod.rs b/backend/crates/providers/openai/src/transport/privacy/mod.rs new file mode 100644 index 000000000..231b1ee9d --- /dev/null +++ b/backend/crates/providers/openai/src/transport/privacy/mod.rs @@ -0,0 +1,275 @@ +//! OpenAI 隐私规则的统一编译与执行,供设置预览和实际出站共享 + +mod carrier; +mod rule; +mod selector; + +use std::{ + collections::BTreeSet, + sync::Arc, + time::{Duration, Instant}, +}; + +use gateway_core::settings::privacy::{ + CodexPrivacyPolicy, CompiledPrivacyPolicy, PrivacyError, PrivacyFailureMode, + PrivacyPreviewRequest, PrivacyPreviewResult, PrivacyRuleOutcome, +}; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; +use serde_json::Value; + +const MAX_VALUE_BYTES: usize = 1024 * 1024; +const MAX_REQUEST_BYTES: usize = 32 * 1024 * 1024; +const MAX_PREVIEW_BYTES: usize = 256 * 1024; + +struct Policy { + enabled: bool, + failure: PrivacyFailureMode, + rules: Vec, +} + +impl std::fmt::Debug for Policy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CompiledCodexPrivacyPolicy") + .field("rule_count", &self.rules.len()) + .finish() + } +} + +struct Budget<'a> { + started: Instant, + steps: usize, + text_bytes: usize, + cancelled: &'a dyn Fn() -> bool, +} + +impl Budget<'_> { + fn step(&mut self) -> Result<(), &'static str> { + self.steps += 1; + if (self.cancelled)() { + return Err("请求已取消或超时"); + } + if self.steps > 10_000 || self.started.elapsed() > Duration::from_millis(100) { + return Err("规则执行预算超限"); + } + Ok(()) + } + + fn text(&mut self, text: &str) -> Result<(), &'static str> { + self.step()?; + self.text_bytes = self.text_bytes.saturating_add(text.len()); + if text.len() > MAX_VALUE_BYTES || self.text_bytes > 16 * MAX_VALUE_BYTES { + return Err("目标文本超出大小限制"); + } + Ok(()) + } +} + +pub fn compile( + policy: &CodexPrivacyPolicy, +) -> Result, PrivacyError> { + if policy.rules.len() > 32 { + return Err(PrivacyError { + rule_index: 0, + reason: "最多配置 32 条规则", + }); + } + let mut ids = BTreeSet::new(); + let rules = policy + .rules + .iter() + .enumerate() + .map(|(rule_index, rule)| { + if !ids.insert(&rule.id) { + return Err(PrivacyError { + rule_index, + reason: "规则 ID 重复", + }); + } + rule::CompiledRule::compile(rule).map_err(|reason| PrivacyError { rule_index, reason }) + }) + .collect::, _>>()?; + Ok(Arc::new(Policy { + enabled: policy.enabled, + failure: policy.on_error, + rules, + })) +} + +impl CompiledPrivacyPolicy for Policy { + fn apply( + &self, + body: &mut Value, + headers: &mut HeaderMap, + turn_metadata: &mut Option, + cancelled: &dyn Fn() -> bool, + ) -> Result, PrivacyError> { + let mut outcomes = Vec::new(); + let mut budget = Budget { + started: Instant::now(), + steps: 0, + text_bytes: 0, + cancelled, + }; + for (rule_index, rule) in self.rules.iter().enumerate() { + let mut outcome = PrivacyRuleOutcome { + rule_id: rule.rule.id.clone(), + matches: 0, + status: "disabled".to_owned(), + reason: None, + }; + if self.enabled && rule.rule.enabled { + // 一条规则的所有承载副本同成同败,失败不会留下部分改写 + let result = (|| { + budget.step()?; + check_size(body, MAX_REQUEST_BYTES)?; + let mut next_body = body.clone(); + let mut next_headers = headers.clone(); + let mut next_metadata = turn_metadata.clone(); + let count = carrier::apply( + rule, + &mut next_body, + &mut next_headers, + &mut next_metadata, + &mut budget, + )?; + check_size(&next_body, MAX_REQUEST_BYTES)?; + budget.step()?; + *body = next_body; + *headers = next_headers; + *turn_metadata = next_metadata; + Ok(count) + })(); + match result { + Ok(count) => { + outcome.matches = count; + outcome.status = + if count == 0 { "unmatched" } else { "applied" }.to_owned(); + } + Err(reason) => { + if self.failure == PrivacyFailureMode::RejectRequest || cancelled() { + return Err(PrivacyError { rule_index, reason }); + } + outcome.status = "skipped".to_owned(); + outcome.reason = Some(reason.to_owned()); + } + } + } + outcomes.push(outcome); + } + Ok(outcomes) + } +} + +pub fn preview(request: PrivacyPreviewRequest) -> Result { + check_size(&request.body, MAX_PREVIEW_BYTES).map_err(|reason| PrivacyError { + rule_index: 0, + reason, + })?; + let size = request + .headers + .iter() + .map(|(key, values)| key.len() + values.iter().map(String::len).sum::()) + .sum::() + + request.turn_metadata.as_ref().map_or(0, String::len); + if size > MAX_PREVIEW_BYTES { + return Err(PrivacyError { + rule_index: 0, + reason: "预览样本最多 256 KiB", + }); + } + let mut headers = HeaderMap::new(); + for (key, values) in request.headers { + let name = HeaderName::from_bytes(key.as_bytes()).map_err(|_| PrivacyError { + rule_index: 0, + reason: "预览请求头名称无效", + })?; + for value in values { + headers.append( + name.clone(), + HeaderValue::from_str(&value).map_err(|_| PrivacyError { + rule_index: 0, + reason: "预览请求头值无效", + })?, + ); + } + } + let mut body = request.body; + let mut turn_metadata = request.turn_metadata; + let outcomes = + compile(&request.policy)?.apply(&mut body, &mut headers, &mut turn_metadata, &|| false)?; + let headers = headers + .keys() + .map(|key| { + ( + key.as_str().to_owned(), + headers + .get_all(key) + .iter() + .filter_map(|value| { + std::str::from_utf8(value.as_bytes()) + .ok() + .map(str::to_owned) + }) + .collect(), + ) + }) + .collect(); + Ok(PrivacyPreviewResult { + body, + headers, + turn_metadata, + outcomes, + }) +} + +fn check_size(value: &Value, limit: usize) -> Result<(), &'static str> { + struct Counter(usize); + impl std::io::Write for Counter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 = self + .0 + .checked_sub(bytes.len()) + .ok_or_else(|| std::io::Error::other("size limit"))?; + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + serde_json::to_writer(Counter(limit), value).map_err(|_| "请求或预览样本超出大小限制") +} + +impl super::client::CodexBackendClient { + pub(crate) fn with_privacy( + mut self, + policy: Option>, + cancellation: gateway_core::lifecycle::CancellationToken, + ) -> Self { + self.privacy = policy.map(|policy| (policy, cancellation)); + self + } + + /// 所有宿主头和 WS 投影完成后再执行,配置者可改写最终业务字段 + pub(super) fn apply_privacy( + &self, + body: &mut Value, + headers: &mut HeaderMap, + ) -> Result<(), super::client::CodexClientError> { + if let Some((policy, cancellation)) = &self.privacy { + let outcomes = + policy.apply(body, headers, &mut None, &|| cancellation.is_cancelled())?; + for (index, outcome) in outcomes + .iter() + .enumerate() + .filter(|(_, outcome)| outcome.status == "skipped") + { + tracing::warn!( + rule_index = index, + reason = outcome.reason, + "隐私规则未生效,按配置跳过" + ); + } + } + Ok(()) + } +} diff --git a/backend/crates/providers/openai/src/transport/privacy/rule.rs b/backend/crates/providers/openai/src/transport/privacy/rule.rs new file mode 100644 index 000000000..f3b5e70a2 --- /dev/null +++ b/backend/crates/providers/openai/src/transport/privacy/rule.rs @@ -0,0 +1,227 @@ +//! 单条规则的编译、捕获组校验和受预算约束的值修改 + +use std::collections::BTreeSet; + +use gateway_core::settings::privacy::{PrivacyAction, PrivacyRule, PrivacyScope}; +use regex::{Regex, RegexBuilder}; +use serde_json::Value; + +use super::{Budget, MAX_VALUE_BYTES, selector}; + +pub(super) struct CompiledRule { + pub(super) rule: PrivacyRule, + pub(super) path: Vec, + regex: Option, +} + +impl CompiledRule { + pub(super) fn compile(rule: &PrivacyRule) -> Result { + if rule.id.is_empty() || rule.id.len() > 64 || rule.name.is_empty() || rule.name.len() > 192 + { + return Err("规则 ID 或名称长度不合法"); + } + if rule.replacement.len() > 4096 + || serde_json::to_vec(&rule.value).map_or(true, |v| v.len() > 4096) + { + return Err("替换内容最多 4096 字节"); + } + let path = if rule.scope == PrivacyScope::RequestHeader { + Vec::new() + } else { + selector::parse(&rule.selector)? + }; + if rule.scope == PrivacyScope::RequestHeader { + reqwest::header::HeaderName::from_bytes(rule.selector.as_bytes()) + .map_err(|_| "请求头名称无效")?; + if rule.action == PrivacyAction::RenameKey { + return Err("请求头范围不支持对象键名替换"); + } + } + if rule.scope == PrivacyScope::EnvironmentText && !path.is_empty() { + return Err("环境文本使用 $ 选择整个文本值"); + } + let regex = rule + .pattern + .as_ref() + .map(|pattern| { + if pattern.len() > 4096 { + return Err("正则表达式最多 4096 字节"); + } + RegexBuilder::new(pattern) + .case_insensitive(rule.case_insensitive) + .multi_line(rule.multi_line) + .size_limit(1024 * 1024) + .dfa_size_limit(1024 * 1024) + .nest_limit(64) + .build() + .map_err(|_| "正则语法无效或复杂度超限,不支持前后顾与模式反向引用") + }) + .transpose()?; + if matches!( + rule.action, + PrivacyAction::RegexReplace | PrivacyAction::RenameKey + ) { + validate_replacement(&rule.replacement, regex.as_ref().ok_or("请填写匹配表达式")?)?; + } + if rule.action == PrivacyAction::SetValue && rule.pattern.is_some() { + return Err("固定值动作不使用匹配表达式"); + } + Ok(Self { + rule: rule.clone(), + path, + regex, + }) + } + + pub(super) fn apply( + &self, + value: &mut Value, + budget: &mut Budget<'_>, + ) -> Result { + let mut count = 0; + let removed = selector::visit(value, &self.path, budget, &mut |value, budget| { + self.apply_value(value, budget, &mut count) + })?; + if removed { + return Err("不能删除作用范围根节点"); + } + Ok(count) + } + + pub(super) fn apply_value( + &self, + value: &mut Value, + budget: &mut Budget<'_>, + count: &mut usize, + ) -> Result { + budget.step()?; + match self.rule.action { + PrivacyAction::RemoveField => { + if let Some(regex) = &self.regex { + let text = value.as_str().ok_or("条件删除只能匹配字符串值")?; + budget.text(text)?; + if !regex.is_match(text) { + return Ok(false); + } + } + *count += 1; + return Ok(true); + } + PrivacyAction::SetValue => { + if std::mem::discriminant(value) != std::mem::discriminant(&self.rule.value) { + return Err("固定值必须保持原字段类型"); + } + *value = self.rule.value.clone(); + *count += 1; + } + PrivacyAction::RegexReplace => { + let text = value.as_str().ok_or("正则替换只能处理字符串")?; + let (replacement, matches) = self.replace(text, budget)?; + *value = Value::String(replacement); + *count += matches; + } + PrivacyAction::RenameKey => { + let object = value.as_object().ok_or("字段名替换只能处理对象")?; + let mut keys = BTreeSet::new(); + let mut changes = Vec::new(); + for key in object.keys() { + budget.step()?; + let (replacement, matches) = self.replace(key, budget)?; + if !keys.insert(replacement.clone()) { + return Err("字段名替换产生重名"); + } + *count += matches; + changes.push(replacement); + } + let Value::Object(object) = std::mem::take(value) else { + return Err("目标类型已变化"); + }; + *value = Value::Object(changes.into_iter().zip(object.into_values()).collect()); + } + } + Ok(false) + } + + fn replace( + &self, + text: &str, + budget: &mut Budget<'_>, + ) -> Result<(String, usize), &'static str> { + budget.text(text)?; + let regex = self.regex.as_ref().ok_or("缺少已编译表达式")?; + let mut output = String::new(); + let mut end = 0; + let mut count = 0; + for captures in regex.captures_iter(text) { + budget.step()?; + let matched = captures.get(0).ok_or("缺少匹配结果")?; + output.push_str(&text[end..matched.start()]); + let largest = captures + .iter() + .flatten() + .map(|group| group.len()) + .max() + .unwrap_or(0); + let references = self + .rule + .replacement + .bytes() + .filter(|byte| *byte == b'$') + .count(); + if output + .len() + .saturating_add(self.rule.replacement.len()) + .saturating_add(largest.saturating_mul(references)) + > MAX_VALUE_BYTES + { + return Err("捕获组展开超出大小限制"); + } + captures.expand(&self.rule.replacement, &mut output); + if output.len() > MAX_VALUE_BYTES { + return Err("替换结果超出大小限制"); + } + end = matched.end(); + count += 1; + if !self.rule.replace_all { + break; + } + } + output.push_str(&text[end..]); + if output.len() > MAX_VALUE_BYTES { + return Err("替换结果超出大小限制"); + } + Ok((output, count)) + } +} + +fn validate_replacement(value: &str, regex: &Regex) -> Result<(), &'static str> { + let mut rest = value; + while let Some(index) = rest.find('$') { + rest = &rest[index + 1..]; + if let Some(tail) = rest.strip_prefix('$') { + rest = tail; + continue; + } + let (name, tail) = if let Some(tail) = rest.strip_prefix('{') { + tail.split_once('}').ok_or("捕获组引用缺少 }")? + } else { + let length = rest + .bytes() + .take_while(|c| c.is_ascii_alphanumeric() || *c == b'_') + .count(); + if length == 0 { + continue; + } + (&rest[..length], &rest[length..]) + }; + let valid = name.parse::().map_or_else( + |_| regex.capture_names().flatten().any(|group| group == name), + |index| index < regex.captures_len(), + ); + if !valid { + return Err("替换内容引用了不存在的捕获组"); + } + rest = tail; + } + Ok(()) +} diff --git a/backend/crates/providers/openai/src/transport/privacy/selector.rs b/backend/crates/providers/openai/src/transport/privacy/selector.rs new file mode 100644 index 000000000..8383beecb --- /dev/null +++ b/backend/crates/providers/openai/src/transport/privacy/selector.rs @@ -0,0 +1,113 @@ +//! 有界 JSON 字段选择器,仅支持成员、索引和单层通配 + +use serde_json::Value; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum Segment { + Key(String), + Index(usize), + Wildcard, +} + +pub(super) fn parse(source: &str) -> Result, &'static str> { + if source.len() > 1024 || !source.starts_with('$') { + return Err("字段路径应以 $ 开头且不超过 1024 字节"); + } + let mut rest = &source[1..]; + let mut segments = Vec::new(); + while !rest.is_empty() { + if segments.len() == 16 { + return Err("字段路径最多 16 层"); + } + if let Some(tail) = rest.strip_prefix('.') { + let end = tail.find(['.', '[']).unwrap_or(tail.len()); + let key = &tail[..end]; + segments.push(if key == "*" { + Segment::Wildcard + } else if !key.is_empty() + && key + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-')) + { + Segment::Key(key.to_owned()) + } else { + return Err("成员名包含特殊字符时使用双引号键名"); + }); + rest = &tail[end..]; + } else if let Some(tail) = rest.strip_prefix('[') { + if tail.starts_with('"') { + let mut decoder = serde_json::Deserializer::from_str(tail).into_iter::(); + let key = decoder + .next() + .and_then(Result::ok) + .ok_or("无效的双引号键名")?; + rest = tail[decoder.byte_offset()..] + .strip_prefix(']') + .ok_or("字段路径缺少 ]")?; + segments.push(Segment::Key(key)); + } else { + let (value, tail) = tail.split_once(']').ok_or("字段路径缺少 ]")?; + segments.push(if value == "*" { + Segment::Wildcard + } else if !value.is_empty() && value.bytes().all(|c| c.is_ascii_digit()) { + Segment::Index(value.parse().map_err(|_| "数组索引超出范围")?) + } else { + return Err("仅支持数组索引和 * 通配,不支持过滤表达式"); + }); + rest = tail; + } + } else { + return Err("无效的字段路径"); + } + } + Ok(segments) +} + +/// 从后向前访问数组,删除匹配项不会改变尚未访问的索引 +pub(super) fn visit( + value: &mut Value, + segments: &[Segment], + budget: &mut super::Budget<'_>, + apply: &mut impl FnMut(&mut Value, &mut super::Budget<'_>) -> Result, +) -> Result { + budget.step()?; + let Some((first, rest)) = segments.split_first() else { + return apply(value, budget); + }; + match (first, value) { + (Segment::Key(key), Value::Object(object)) => { + if let Some(child) = object.get_mut(key) + && visit(child, rest, budget, apply)? + { + object.remove(key); + } + } + (Segment::Index(index), Value::Array(items)) => { + if let Some(child) = items.get_mut(*index) + && visit(child, rest, budget, apply)? + { + items.remove(*index); + } + } + (Segment::Wildcard, Value::Object(object)) => { + let keys: Vec<_> = object.keys().cloned().collect(); + for key in keys { + if let Some(child) = object.get_mut(&key) + && visit(child, rest, budget, apply)? + { + object.remove(&key); + } + } + } + (Segment::Wildcard, Value::Array(items)) => { + for index in (0..items.len()).rev() { + if visit(&mut items[index], rest, budget, apply)? { + items.remove(index); + } + } + } + // 缺少字段属于正常未命中,已存在却不符合路径类型属于错误 + _ => return Err("目标结构不符合字段路径"), + } + Ok(false) +} diff --git a/backend/crates/providers/openai/src/transport/request.rs b/backend/crates/providers/openai/src/transport/request.rs index 5f0ed91e7..295c33c54 100644 --- a/backend/crates/providers/openai/src/transport/request.rs +++ b/backend/crates/providers/openai/src/transport/request.rs @@ -612,6 +612,17 @@ fn metadata_string(request: &CodexResponsesRequest, key: &str) -> Option .map(ToOwned::to_owned) } +pub(crate) fn serialize_ascii_turn_metadata(value: &Value) -> Option { + let mut bytes = Vec::new(); + value + .serialize(&mut serde_json::Serializer::with_formatter( + &mut bytes, + AsciiTurnMetadataFormatter, + )) + .ok()?; + String::from_utf8(bytes).ok() +} + pub(crate) fn scope_turn_metadata( raw: &str, installation_id: &str, @@ -648,14 +659,7 @@ pub(crate) fn scope_turn_metadata( // 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() + serialize_ascii_turn_metadata(&Value::Object(metadata)) } struct AsciiTurnMetadataFormatter; diff --git a/backend/crates/providers/openai/tests/provider/contract/mod.rs b/backend/crates/providers/openai/tests/provider/contract/mod.rs index 5c18742be..dc0152e16 100644 --- a/backend/crates/providers/openai/tests/provider/contract/mod.rs +++ b/backend/crates/providers/openai/tests/provider/contract/mod.rs @@ -7,6 +7,7 @@ mod image_account_eligibility; mod image_model_access; mod passthrough; mod precommit; +mod privacy; mod response_interrupt; mod session_binding; mod timing; diff --git a/backend/crates/providers/openai/tests/provider/contract/privacy.rs b/backend/crates/providers/openai/tests/provider/contract/privacy.rs new file mode 100644 index 000000000..c5147c3cd --- /dev/null +++ b/backend/crates/providers/openai/tests/provider/contract/privacy.rs @@ -0,0 +1,331 @@ +//! 隐私规则在真实 HTTP、WebSocket 和发送前拒绝边界的合同 + +use super::*; +use crate::transport::privacy::{policy, rule}; +use gateway_core::settings::privacy::{CodexPrivacyPolicy, PrivacyAction, PrivacyScope}; + +fn privacy_context(policy: &CodexPrivacyPolicy, fallback: bool) -> AttemptContext { + let context = AttemptContext::new( + RequestAttemptContext::new( + ModelRequestId::new("req_privacy").unwrap(), + ClientApiKeyId::new("key_openai_contract").unwrap(), + ) + .with_privacy(Some( + provider_openai::transport::privacy::compile(policy).unwrap(), + )), + NonZeroU32::MIN, + SystemTime::now() + Duration::from_secs(30), + account_policy(), + AccountAttemptContext::new(BTreeSet::new(), None, None) + .with_account_scope(contract_account_scope()), + None, + CancellationToken::new(), + ); + if fallback { + context.with_transport(AttemptTransport::Fallback) + } else { + context + } +} + +fn outbound_policy() -> CodexPrivacyPolicy { + let mut auth = rule( + PrivacyScope::RequestHeader, + "authorization", + PrivacyAction::SetValue, + ); + auth.value = json!("Bearer configured-synthetic-token"); + let mut input = rule( + PrivacyScope::RequestBody, + "$.input[0].content[0].text", + PrivacyAction::RegexReplace, + ); + input.id = "input".into(); + input.pattern = Some("hello".into()); + input.replacement = "hellohello".into(); + let mut metadata = rule( + PrivacyScope::TurnMetadata, + "$.workspaces", + PrivacyAction::RemoveField, + ); + metadata.id = "metadata".into(); + policy(vec![auth, input, metadata]) +} + +fn metadata_operation() -> Operation { + let header = + json!({"workspaces":{"/home/alex/private":{}},"request_kind":"turn","header_only":true}); + let client = json!({"workspaces":{"/home/alex/private":{}},"tool_namespaces_info":{"tools":["example"]}}); + let body = json!({"model":"gpt-5.4","input":"hello","turnMetadata":header.to_string(),"client_metadata":{"x-codex-turn-metadata":client.to_string()}}); + Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object("openai", body.as_object().unwrap().clone()).unwrap(), + )) +} + +fn assert_metadata_filtered(raw: &str, client_copy: bool) { + let metadata: Value = serde_json::from_str(raw).unwrap(); + assert!(metadata.get("workspaces").is_none()); + if client_copy { + assert_eq!( + metadata["tool_namespaces_info"], + json!({"tools":["example"]}) + ); + assert!(metadata.get("header_only").is_none()); + } else { + assert_eq!(metadata["header_only"], true); + assert!(metadata.get("tool_namespaces_info").is_none()); + } +} + +#[tokio::test] +async fn privacy_json_endpoints_preserve_untouched_bytes_and_rewrite_matched_body() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"output":"ok"}))) + .mount(&server) + .await; + let provider = provider_with_base_url(&store, server.uri()); + let raw = Bytes::from_static( + br#"{ "id":"search", "workspaces":{"/private":{}}, "future":1, "future":2 }"#, + ); + for change_body in [false, true] { + let mut config = outbound_policy(); + config.rules.truncate(1); + if change_body { + let mut remove = rule( + PrivacyScope::RequestBody, + "$.workspaces", + PrivacyAction::RemoveField, + ); + remove.id = "remove-workspace".into(); + config.rules.push(remove); + } + let operation = Operation::Search(StandaloneSearchRequest::from_raw_json( + RawJsonPayload::new("openai", raw.clone()).unwrap(), + )); + let mut stream = provider + .clone() + .execute( + planned_provider_endpoint_request("openai", operation), + privacy_context(&config, false), + ) + .await + .unwrap(); + while let Some(event) = stream.next().await { + event.unwrap(); + } + let requests = server.received_requests().await.unwrap(); + let sent = requests.last().unwrap(); + assert_eq!( + sent.headers["authorization"], + "Bearer configured-synthetic-token" + ); + if change_body { + assert_eq!( + serde_json::from_slice::(&sent.body).unwrap(), + json!({"id":"search","future":2}) + ); + } else { + assert_eq!(sent.body, raw); + } + } +} + +#[tokio::test] +async fn privacy_rewrites_final_http_headers_and_body_once_per_attempt() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(CAPTURE_COMPLETED_SSE), + ) + .mount(&server) + .await; + let provider = provider_with_base_url(&store, server.uri()); + let operation = metadata_operation(); + for _ in 0..2 { + let mut stream = provider + .clone() + .execute( + planned_request("openai", operation.clone()), + privacy_context(&outbound_policy(), true), + ) + .await + .unwrap(); + while let Some(event) = stream.next().await { + event.unwrap(); + } + } + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 2); + for request in requests { + assert_metadata_filtered( + request.headers["x-codex-turn-metadata"].to_str().unwrap(), + false, + ); + assert_metadata_filtered( + captured_request_body(&request)["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .unwrap(), + true, + ); + assert_eq!( + request.headers["authorization"], + "Bearer configured-synthetic-token" + ); + assert_eq!( + captured_request_body(&request)["input"][0]["content"][0]["text"], + "hellohello" + ); + } +} + +#[tokio::test] +async fn privacy_rejection_is_not_sent_and_cannot_retry_or_rotate() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let server = MockServer::start().await; + let mut invalid_target = rule( + PrivacyScope::RequestBody, + "$.input[0].content[0].text", + PrivacyAction::SetValue, + ); + invalid_target.value = json!(42); + let provider = provider_with_base_url(&store, server.uri()); + let result = provider + .execute( + planned_request("openai", generate_operation()), + privacy_context(&policy(vec![invalid_target]), true), + ) + .await; + let error = match result { + Err(error) => error, + Ok(mut stream) => stream.next().await.unwrap().err().unwrap(), + }; + assert_eq!(error.kind(), ProviderErrorKind::InvalidRequest); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + assert!(error.retry_is_prohibited()); + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn privacy_rewrites_websocket_handshake_and_frame_once() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let captured = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = + crate::transport::accept_codex_test_websocket_with(stream, |request, response| { + assert_eq!( + request.headers()["authorization"], + "Bearer configured-synthetic-token" + ); + assert_metadata_filtered( + request.headers()["x-codex-turn-metadata"].to_str().unwrap(), + false, + ); + response.headers_mut().insert( + "sec-websocket-extensions", + "permessage-deflate".parse().unwrap(), + ); + }) + .await; + let frame = socket.next().await.unwrap().unwrap().into_text().unwrap(); + let body: Value = serde_json::from_str(&frame).unwrap(); + assert_metadata_filtered( + body["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .unwrap(), + true, + ); + assert_eq!(body["input"][0]["content"][0]["text"], "hellohello"); + socket.send(Message::Text(json!({"type":"response.completed","response":{"id":"resp_privacy","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}).to_string().into())).await.unwrap(); + }); + let provider = provider_with_base_url(&store, format!("http://{address}")); + let mut stream = provider + .execute( + planned_request("openai", metadata_operation()), + privacy_context(&outbound_policy(), false), + ) + .await + .unwrap(); + while let Some(event) = stream.next().await { + event.unwrap(); + } + captured.await.unwrap(); +} + +#[tokio::test] +async fn privacy_header_change_uses_a_new_websocket_pool_connection() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let captured = tokio::spawn(async move { + let mut sockets = Vec::new(); + for expected in [ + "Bearer first-synthetic-token", + "Bearer second-synthetic-token", + ] { + let (stream, _) = timeout(Duration::from_secs(5), listener.accept()) + .await + .unwrap() + .unwrap(); + let mut socket = + crate::transport::accept_codex_test_websocket_with(stream, |request, response| { + assert_eq!(request.headers()["authorization"], expected); + response.headers_mut().insert( + "sec-websocket-extensions", + "permessage-deflate".parse().unwrap(), + ); + }) + .await; + let frame = socket.next().await.unwrap().unwrap().into_text().unwrap(); + assert_eq!( + serde_json::from_str::(&frame).unwrap()["input"][0]["content"][0]["text"], + "hellohello" + ); + socket.send(Message::Text(json!({"type":"response.completed","response":{"id":"resp_privacy_pool","model":"gpt-5.4","status":"completed","output":[]}}).to_string().into())).await.unwrap(); + // 保留第一条连接,确保下一次握手是池隔离的结果而非断线重连 + sockets.push(socket); + } + sockets + }); + let provider = provider_with_base_url(&store, format!("http://{address}")); + for token in [ + "Bearer first-synthetic-token", + "Bearer second-synthetic-token", + ] { + let mut config = outbound_policy(); + config.rules[0].value = json!(token); + let operation = Operation::Generate(generate_with_session_context( + "privacy-pool", + Some("privacy-thread"), + None, + )); + let mut stream = provider + .clone() + .execute( + planned_request("openai", operation), + privacy_context(&config, false), + ) + .await + .unwrap(); + timeout(Duration::from_secs(5), async { + while let Some(event) = stream.next().await { + event.unwrap(); + } + }) + .await + .unwrap(); + } + captured.await.unwrap(); +} diff --git a/backend/crates/providers/openai/tests/transport/mod.rs b/backend/crates/providers/openai/tests/transport/mod.rs index d60d011ea..b28de67cc 100644 --- a/backend/crates/providers/openai/tests/transport/mod.rs +++ b/backend/crates/providers/openai/tests/transport/mod.rs @@ -58,6 +58,7 @@ mod endpoints; mod headers; mod http_client; mod latency; +pub(crate) mod privacy; mod profile; mod profile_avatar; mod profile_contract; diff --git a/backend/crates/providers/openai/tests/transport/privacy/mod.rs b/backend/crates/providers/openai/tests/transport/privacy/mod.rs new file mode 100644 index 000000000..4c41fc8f1 --- /dev/null +++ b/backend/crates/providers/openai/tests/transport/privacy/mod.rs @@ -0,0 +1,385 @@ +//! 隐私规则的多承载一致性、自由字段配置、条件删除与有界失败合同 + +use gateway_core::settings::privacy::*; +use provider_openai::transport::privacy::{compile, preview}; +use serde_json::{Value, json}; + +pub(crate) fn rule(scope: PrivacyScope, selector: &str, action: PrivacyAction) -> PrivacyRule { + PrivacyRule { + id: "rule-1".into(), + name: "测试规则".into(), + enabled: true, + scope, + selector: selector.into(), + action, + pattern: None, + replacement: String::new(), + value: Value::Null, + replace_all: true, + case_insensitive: false, + multi_line: false, + } +} + +pub(crate) fn policy(rules: Vec) -> CodexPrivacyPolicy { + CodexPrivacyPolicy { + enabled: true, + on_error: PrivacyFailureMode::RejectRequest, + rules, + } +} + +fn sample(rules: Vec, body: Value) -> PrivacyPreviewRequest { + PrivacyPreviewRequest { + policy: policy(rules), + body, + headers: Default::default(), + turn_metadata: None, + } +} + +#[test] +fn privacy_removes_workspaces_from_every_copy_without_merging_metadata() { + let workspaces = json!({"/home/alex/项目": {"associated_remote_urls": {"origin":"https://example.test/private/repo"}}}); + let header = json!({"workspaces":workspaces,"request_kind":"primary"}); + let client = json!({"workspaces":workspaces,"tool_namespaces_info":{"tools":["a"]}}); + let mut request = sample( + vec![rule( + PrivacyScope::TurnMetadata, + "$.workspaces", + PrivacyAction::RemoveField, + )], + json!({ + "turnMetadata": header.to_string(), "turn_metadata": header, "x-codex-turn-metadata": header, + "client_metadata": {"x-codex-turn-metadata":client.to_string()} + }), + ); + request.turn_metadata = Some(header.to_string()); + request.headers.insert( + "x-codex-turn-metadata".into(), + vec![header.to_string(), header.to_string()], + ); + let output = preview(request).unwrap(); + assert_eq!(output.outcomes[0].matches, 7); + assert_eq!( + output.turn_metadata.as_deref(), + Some("{\"request_kind\":\"primary\"}") + ); + assert_eq!( + output.body["turn_metadata"], + json!({"request_kind":"primary"}) + ); + assert_eq!( + serde_json::from_str::( + output.body["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .unwrap() + ) + .unwrap(), + json!({"tool_namespaces_info":{"tools":["a"]}}) + ); + assert!( + output.headers["x-codex-turn-metadata"] + .iter() + .all(|raw| !raw.contains("workspaces")) + ); +} + +#[test] +fn privacy_conditionally_deletes_values_and_renames_path_keys_in_sequence() { + let mut remove = rule( + PrivacyScope::RequestBody, + "$.workspaces.*.associated_remote_urls.*", + PrivacyAction::RemoveField, + ); + remove.pattern = Some("/private-org/".into()); + let mut rename = rule( + PrivacyScope::RequestBody, + "$.workspaces", + PrivacyAction::RenameKey, + ); + rename.id = "rename".into(); + rename.pattern = Some("^/home/[^/]+/(.*)$".into()); + rename.replacement = "/workspace/${1}".into(); + let output = preview(sample(vec![remove, rename], json!({"workspaces":{ + "/home/alex/demo":{"associated_remote_urls":{"origin":"https://host/private-org/demo","upstream":"https://host/public/demo"}} + }}))).unwrap(); + assert_eq!( + output.body, + json!({"workspaces":{"/workspace/demo":{"associated_remote_urls":{"upstream":"https://host/public/demo"}}}}) + ); + let mut remove = rule( + PrivacyScope::RequestBody, + "$.remotes[*]", + PrivacyAction::RemoveField, + ); + remove.pattern = Some("private".into()); + assert_eq!( + preview(sample( + vec![remove], + json!({"remotes":["private-a","public","private-b","private-c"]}) + )) + .unwrap() + .body, + json!({"remotes":["public"]}) + ); +} + +#[test] +fn privacy_has_no_field_protection_list_in_any_scope() { + for name in [ + "authorization", + "cookie", + "chatgpt-account-id", + "x-codex-turn-state", + "x-codex-installation-id", + ] { + let mut request = sample( + vec![rule( + PrivacyScope::RequestHeader, + name, + PrivacyAction::RemoveField, + )], + json!({}), + ); + request + .headers + .insert(name.into(), vec!["synthetic-value".into()]); + assert!(preview(request).unwrap().headers.is_empty(), "{name}"); + } + for name in [ + "session_id", + "thread_id", + "previous_response_id", + "prompt_cache_key", + "tool_namespaces_info", + "history_ingest_requested", + "analytics_enabled", + ] { + let output = preview(sample( + vec![rule( + PrivacyScope::RequestBody, + &format!("$.{name}"), + PrivacyAction::RemoveField, + )], + json!({name:"synthetic"}), + )) + .unwrap(); + assert_eq!(output.body, json!({}), "{name}"); + } +} + +#[test] +fn privacy_rolls_back_all_copies_of_a_failed_rule_and_continues_in_order() { + let mut first = rule( + PrivacyScope::TurnMetadata, + "$.workspace", + PrivacyAction::RegexReplace, + ); + first.pattern = Some("private".into()); + first.replacement = "public".into(); + let mut second = rule(PrivacyScope::RequestBody, "$.keep", PrivacyAction::SetValue); + second.id = "second".into(); + second.value = json!("done"); + let body = json!({"turnMetadata":{"workspace":"private"},"client_metadata":{"turnMetadata":{"workspace":42}},"keep":"start"}); + let mut request = sample(vec![first.clone(), second], body.clone()); + request.policy.on_error = PrivacyFailureMode::SkipRule; + let output = preview(request).unwrap(); + assert_eq!(output.body["turnMetadata"], body["turnMetadata"]); + assert_eq!(output.body["keep"], "done"); + assert_eq!(output.outcomes[0].status, "skipped"); + assert_eq!(output.outcomes[1].status, "applied"); + assert_eq!( + preview(sample(vec![first], body)).err().unwrap().rule_index, + 0 + ); +} + +#[test] +fn privacy_key_collisions_fail_without_losing_values() { + let mut rename = rule( + PrivacyScope::RequestBody, + "$.workspaces", + PrivacyAction::RenameKey, + ); + rename.pattern = Some(".+".into()); + rename.replacement = "same".into(); + let body = json!({"workspaces":{"first":1,"second":2}}); + let mut request = sample(vec![rename], body.clone()); + request.policy.on_error = PrivacyFailureMode::SkipRule; + let output = preview(request).unwrap(); + assert_eq!(output.body, body); + assert_eq!(output.outcomes[0].status, "skipped"); +} + +#[test] +fn privacy_desktop_context_is_classified_and_reencoded_without_touching_prose() { + let text = "{\"workspaces\":{\"/private\":{}},\"note\":\"a & b\"}"; + let mut replacement = rule( + PrivacyScope::DesktopGitContext, + "$.workspaces", + PrivacyAction::RemoveField, + ); + let body = json!({"input":[{"role":"user","content":[{"type":"input_text","text":text},{"type":"input_text","text":text}],"internal_chat_message_metadata_passthrough":{"content_item_kinds":["additional_content.codex_git_state","user.text"]}}]}); + let output = preview(sample(vec![replacement.clone()], body)).unwrap(); + assert!( + !output.body["input"][0]["content"][0]["text"] + .as_str() + .unwrap() + .contains("workspaces") + ); + assert!( + output.body["input"][0]["content"][0]["text"] + .as_str() + .unwrap() + .contains("a & b") + ); + assert_eq!(output.body["input"][0]["content"][1]["text"], text); + replacement.scope = PrivacyScope::EnvironmentText; + replacement.selector = "$".into(); + replacement.action = PrivacyAction::RegexReplace; + replacement.pattern = Some("Asia/Shanghai".into()); + replacement.replacement = "UTC".into(); + let body = json!({"input":[{"role":"developer","content":[{"type":"input_text","text":"Asia/Shanghai"}]}]}); + assert!( + preview(sample(vec![replacement], body)) + .unwrap() + .body + .to_string() + .contains("UTC") + ); +} + +#[test] +fn privacy_validates_regex_paths_captures_and_execution_limits() { + let mut replace = rule( + PrivacyScope::RequestBody, + "$[\"a.b\"][0]", + PrivacyAction::RegexReplace, + ); + replace.pattern = Some("(?Pa)".into()); + replace.replacement = "${word}${1}$$".into(); + assert_eq!( + preview(sample(vec![replace.clone()], json!({"a.b":["a"]}))) + .unwrap() + .body, + json!({"a.b":["aa$"]}) + ); + replace.replacement = "${missing}".into(); + assert!(compile(&policy(vec![replace.clone()])).is_err()); + replace.replacement.clear(); + replace.pattern = Some("(?<=a)b".into()); + assert!(compile(&policy(vec![replace])).is_err()); + for path in ["$..secret", "$[?(@.secret)]", "$['single']"] { + assert!( + compile(&policy(vec![rule( + PrivacyScope::RequestBody, + path, + PrivacyAction::RemoveField + )])) + .is_err() + ); + } + let policy = compile(&policy(vec![rule( + PrivacyScope::RequestBody, + "$.items.*.missing", + PrivacyAction::RemoveField, + )])) + .unwrap(); + let mut body = json!({"items":vec![json!({}); 10_001]}); + let original = body.clone(); + let error = policy + .apply(&mut body, &mut Default::default(), &mut None, &|| false) + .unwrap_err(); + assert_eq!(error.reason, "规则执行预算超限"); + assert_eq!(body, original); + assert!( + policy + .apply(&mut json!({}), &mut Default::default(), &mut None, &|| true) + .is_err() + ); +} + +#[test] +fn privacy_disabled_policy_and_absent_fields_preserve_original_bytes() { + let body = json!({"turnMetadata":"{ \"keep\" : 1 }"}); + let request = sample( + vec![rule( + PrivacyScope::TurnMetadata, + "$.workspaces", + PrivacyAction::RemoveField, + )], + body.clone(), + ); + assert_eq!(preview(request.clone()).unwrap().body, body); + let mut request = request; + request.policy.enabled = false; + assert_eq!(preview(request).unwrap().outcomes[0].status, "disabled"); +} + +#[test] +fn privacy_context_parser_handles_quoted_brackets_and_bounds_xml_depth() { + let text = "b\">"; + let body = json!({"input":[{"role":"user","content":[{"type":"input_text","text":text}]}]}); + let output = preview(sample( + vec![rule( + PrivacyScope::DesktopGitContext, + "$.workspaces", + PrivacyAction::RemoveField, + )], + body, + )) + .unwrap(); + let changed = output.body["input"][0]["content"][0]["text"] + .as_str() + .unwrap(); + let document = roxmltree::Document::parse(changed).unwrap(); + let git = document + .descendants() + .find(|node| node.has_tag_name("git")) + .unwrap(); + assert_eq!(git.attribute("source"), Some("a>b")); + assert_eq!( + serde_json::from_str::(git.text().unwrap()).unwrap(), + json!({"note":"a & b"}) + ); + let nested = format!( + "{}value{}", + "".repeat(100), + "".repeat(100) + ); + let mut replace = rule( + PrivacyScope::EnvironmentText, + "$", + PrivacyAction::RegexReplace, + ); + replace.pattern = Some("value".into()); + replace.replacement = "changed".into(); + let body = json!({"input":[{"role":"user","content":[{"type":"input_text","text":nested}]}]}); + assert_eq!( + preview(sample(vec![replace], body)).err().unwrap().reason, + "上下文 XML 嵌套超过 64 层" + ); +} + +#[test] +fn privacy_reports_headers_that_websocket_opening_cannot_serialize() { + let mut replace = rule( + PrivacyScope::RequestHeader, + "x-label", + PrivacyAction::SetValue, + ); + replace.value = json!("别名"); + let mut request = sample(vec![replace], json!({})); + request + .headers + .insert("x-label".into(), vec!["original".into()]); + assert_eq!( + preview(request.clone()).err().unwrap().reason, + "替换结果必须是 ASCII 请求头值" + ); + request.policy.on_error = PrivacyFailureMode::SkipRule; + let result = preview(request).unwrap(); + assert_eq!(result.headers["x-label"], vec!["original"]); + assert_eq!(result.outcomes[0].status, "skipped"); +} diff --git a/backend/migrations/.frozen-sha256 b/backend/migrations/.frozen-sha256 index 925142d9f..2708c977b 100644 --- a/backend/migrations/.frozen-sha256 +++ b/backend/migrations/.frozen-sha256 @@ -23,3 +23,4 @@ e8c1339166ac02fb2d4a077424ee5f64f6dc20eb4d3f8ec4fcb09c0f2a0e61f6 0020_plugin_tr d431ebf24669451da06bb6671136bf58cddcc713b43a1a705eb33f9cb4f9b577 0023_error_details_and_account_affinity.sql fa99dde0fe0b3d7da6693d76b2735629dde0dc255ea8c53f0354857a4e80669b 0024_preferred_account_affinity.sql 299d347add09b1e6f086732c0db8269efb640db4ecccb948e17c067b572afc43 0025_request_observation.sql +d4b599066d7382a1c9cb6b3c003200046579e4fce3531f232cbd665a9d92c542 0026_codex_privacy_policy.sql diff --git a/backend/migrations/0026_codex_privacy_policy.sql b/backend/migrations/0026_codex_privacy_policy.sql new file mode 100644 index 000000000..0be3a97fc --- /dev/null +++ b/backend/migrations/0026_codex_privacy_policy.sql @@ -0,0 +1,11 @@ +-- 隐私规则默认关闭,保留既有请求行为 +alter table runtime_settings + add column codex_privacy_policy_json jsonb not null + default '{"enabled":false,"onError":"skip_rule","rules":[]}'::jsonb + check ( + jsonb_typeof(codex_privacy_policy_json) = 'object' + and jsonb_typeof(codex_privacy_policy_json->'enabled') = 'boolean' + and codex_privacy_policy_json->>'onError' in ('skip_rule', 'reject_request') + and jsonb_typeof(codex_privacy_policy_json->'rules') = 'array' + and codex_privacy_policy_json ?& array['enabled', 'onError', 'rules'] + ); diff --git a/docs/api.md b/docs/api.md index 8eecbf6c9..420dbccd4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1322,6 +1322,7 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly | --- | --- | --- | | `GET` | `/api/admin/settings` | 读取运行设置 | | `POST` | `/api/admin/settings/update` | 原子保存运行设置;身份配置按显式提供的 Provider 项更新 | +| `POST` | `/api/admin/settings/privacy/preview` | 使用管理员提供的样本预览隐私规则,不保存设置或样本 | | `GET` | `/api/admin/settings/client-downloads/codex-desktop/windows` | 提取 Codex Desktop Windows 离线安装直链;`refresh=true` 强制刷新进程内短缓存 | | `GET` | `/api/admin/settings/client-profiles/{provider}` | 读取 `openai` 或 `xai` 的可选配置和 `globalConfiguration` | | `POST` | `/api/admin/settings/client-profiles/{provider}/preview` | `{ configuration }`,完整配置对象或 `null`(解析当前通用设置);只预览,不保存 | @@ -1340,6 +1341,7 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly | 并发控制版本 | `configRevision` | | [客户端身份](#provider-客户端身份) | `providerRequestProfiles`、`openaiClientProfile`、`xaiClientProfile` | | [请求位置](#请求位置) | `requestLocationEnabled`、`requestLocation` | +| [隐私策略](#隐私策略) | `codexPrivacyPolicy` | | 模型映射 | `modelMappings` | | 凭据刷新 | `refreshMarginSeconds`、`refreshConcurrency` | | [并发与排队](#并发与排队) | `maxConcurrentPerAccount`、`maxWaitingPerKey`、`maxWaitingPerAccount`、`openaiGuardianReservedConcurrency`、`concurrencyWaitTimeoutSeconds`、`requestIntervalMs` | @@ -1369,6 +1371,47 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly 关联代理配置了自定义位置时优先使用代理值。保存后通过现有配置发布机制对新请求生效, 已开始请求及其重试保持同一份全局值;普通文本、绝对时间戳和数据驻留要求不受影响 +### 隐私策略 + +`codexPrivacyPolicy` 为必填对象,默认 `{ enabled: false, onError: "skip_rule", rules: [] }`。 +只处理经网关发送的 OpenAI Responses HTTP/WebSocket 和独立 JSON 端点请求,不控制客户端直接发送的遥测。 +规则按列表顺序执行,关闭策略或单条规则会保留配置。没有字段保护名单,认证、会话、工具等字段也可配置;改写后对上游行为的影响由配置者承担 + +每条规则包含唯一 `id`、`name`、`enabled`、`scope`、`selector`、`action`、`pattern`、`replacement`、 +`value`、`replaceAll`、`caseInsensitive`、`multiLine`。作用范围如下: + +| `scope` | 选择对象 | +| --- | --- | +| `turn_metadata` | 请求头、正文及 `client_metadata` 中的 turn metadata 对象,各副本独立处理 | +| `desktop_git_context` | 自动附加的 Desktop Git 上下文中解码后的 JSON 对象 | +| `environment_text` | 已识别的环境上下文或 Desktop 时间上下文文本,`selector` 为 `$` | +| `request_body` | 最终出站 JSON 正文 | +| `request_header` | 最终业务请求头,`selector` 直接填写头名称,重复头逐个处理 | + +JSON 路径支持 `$`、`.成员`、`["带特殊字符的键"]`、`[索引]`、`.*` 和 `[*]`,不支持递归和过滤表达式。 +缺少字段视为未命中;类型不符属于执行失败。`regex_replace` 替换字符串,`set_value` 保持原字段类型设置 JSON 值, +`rename_key` 替换所选对象的直接键名,重名时失败;`remove_field` 删除所选字段或数组项,不能删除作用范围根节点。 +删除动作的 `pattern: null` 表示无条件删除,字符串模式表示只删除字符串值匹配的字段或数组项。 +请求头规则生成的值必须是 ASCII 文本,以保持 HTTP 与 WebSocket opening 的一致性;metadata 范围会自动转义 Unicode + +例如,`turn_metadata` 范围的 `$.workspaces` 配合 `remove_field` 删除整个工作区映射; +`$.workspaces.*.associated_remote_urls.*` 配合 `remove_field` 和 `pattern: "private-org"` 只删除命中的远端; +`$.workspaces` 配合 `rename_key` 可改写工作区路径键名。环境文本替换必须保留 XML 包裹结构 + +正则使用 Rust `regex` 语法,不支持前后顾或模式反向引用;替换内容支持 `${1}`、`${name}` 和字面量 `$$`。 +`replaceAll` 控制首个或全部匹配,另支持忽略大小写和多行锚点。最多 32 条规则,路径最多 16 层、1024 字节, +模式与替换内容分别最多 4096 字节,固定值编码后最多 4096 字节。XML 上下文最多 10000 个节点,环境文本最多 64 层。 +执行限制为正文 32 MiB、单目标文本及编码后的上下文 1 MiB、 +累计处理文本 16 MiB、10000 次访问与匹配步骤、100 ms 检查预算;时间预算在处理检查点检查,不是硬实时中断 + +执行失败时,`skip_rule` 撤销本条规则在所有副本上的修改并继续,意味着本条脱敏未完成; +`reject_request` 阻止发送并返回本地请求错误,不因该错误自动重试或换号。设置保存时校验所有规则,包含未启用的规则 + +预览请求为 `{ policy, body, headers, turnMetadata }`,其中 `headers` 为头名称到字符串数组的映射, +`turnMetadata` 为编码后的 JSON 字符串或 `null`。正文最多 256 KiB,请求头与独立 metadata 合计最多 256 KiB。 +响应返回处理后的三个承载值,以及 `outcomes: [{ ruleId, matches, status, reason }]`; +状态为 `disabled`、`unmatched`、`applied` 或 `skipped`,错误不回显请求内容或表达式 + ### 并发与排队 `maxConcurrentPerAccount` 是默认账号并发上限,取值 0~4294967295;`0` 表示不限制。 diff --git a/docs/architecture.md b/docs/architecture.md index b1c1b6372..48247ed32 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -293,6 +293,12 @@ service;客户端原生对象保存在有界进程缓存中,与套餐 Redis `RuntimeSnapshot → RoutingPlan → AttemptContext` 冻结传递,关闭时保留客户端原有字段; Provider 在选定账号后应用代理位置覆盖。请求期间不额外查询全局设置,配置发布不改变已开始请求的全局值 +隐私规则合同及编译端口由 Core 定义,OpenAI Provider 拥有路径、正则和承载结构的解释,Admin 的保存校验与样本预览复用同一实现。 +Store 将策略与设置 revision 原子持久化;编译结果随快照、计划及 attempt 冻结,未改变策略的请求设置覆盖复用已编译对象。 +规则在宿主请求头和 WS metadata 投影完成后作用于本次出站副本,重试及传输回退从原请求重新构造,避免重复替换。 +每条规则跨承载副本原子提交;发送前拒绝携带 `NotSent` 和禁止重试标记。被规则改写的握手头参与 WS 池身份,防止复用旧值, +关闭策略不进入改写管线。字段与执行限制见[隐私策略 API](api.md#隐私策略) + Fast 限制由同一快照链路冻结:Client Key 所有绑定分组的开关取逻辑或; 禁用分组仍贡献限制,无分组 Key 不限制 Fast,不按所选账号的分组重新解释。 OpenAI Provider 在独立编码请求上、生成上游头与观测前统一应用顶层档位覆盖,HTTP、WS 与重试共用。 diff --git a/frontend/src/api/modules/settings/index.ts b/frontend/src/api/modules/settings/index.ts index cb7a09070..3c05c87c9 100644 --- a/frontend/src/api/modules/settings/index.ts +++ b/frontend/src/api/modules/settings/index.ts @@ -1,9 +1,11 @@ import type { RequestOptions } from '../../request' +import type { CodexPrivacyPolicy } from './privacy' import type { ClientProfileSelection, ProviderRequestProfiles, ProviderRequestProfileUpdates, XaiClientProfileSelection } from './profiles' import request from '../../request' export * from './backups' export * from './pricing' +export * from './privacy' export * from './system' export interface RequestLocation { @@ -35,6 +37,7 @@ export interface RuntimeSettings { openaiClientProfile: ClientProfileSelection | null xaiClientProfile: XaiClientProfileSelection | null + codexPrivacyPolicy: CodexPrivacyPolicy requestLocationEnabled: boolean requestLocation: RequestLocation modelMappings: Record diff --git a/frontend/src/api/modules/settings/privacy.ts b/frontend/src/api/modules/settings/privacy.ts new file mode 100644 index 000000000..17b88b05b --- /dev/null +++ b/frontend/src/api/modules/settings/privacy.ts @@ -0,0 +1,46 @@ +import type { RequestOptions } from '../../request' +import request from '../../request' + +export type PrivacyScope = 'turn_metadata' | 'desktop_git_context' | 'environment_text' | 'request_body' | 'request_header' +export type PrivacyAction = 'regex_replace' | 'set_value' | 'rename_key' | 'remove_field' +export type JsonValue = unknown + +export interface PrivacyRule { + id: string + name: string + enabled: boolean + scope: PrivacyScope + selector: string + action: PrivacyAction + pattern: string | null + replacement: string + value: JsonValue + replaceAll: boolean + caseInsensitive: boolean + multiLine: boolean +} + +export interface CodexPrivacyPolicy { + enabled: boolean + onError: 'skip_rule' | 'reject_request' + rules: PrivacyRule[] +} + +export interface PrivacySample { + body: JsonValue + headers: Record + turnMetadata: string | null +} + +export interface PrivacyPreviewResult extends PrivacySample { + outcomes: { ruleId: string, matches: number, status: string, reason: string | null }[] +} + +export function previewPrivacyPolicy(data: PrivacySample & { policy: CodexPrivacyPolicy }, options: RequestOptions = {}) { + return request({ + url: '/api/admin/settings/privacy/preview', + method: 'POST', + data, + ...options, + }) +} diff --git a/frontend/src/views/settings/components/AccountAffinityCard.vue b/frontend/src/views/settings/components/AccountAffinityCard.vue index 19dd2b13d..e69c38a56 100644 --- a/frontend/src/views/settings/components/AccountAffinityCard.vue +++ b/frontend/src/views/settings/components/AccountAffinityCard.vue @@ -1,6 +1,6 @@ + + diff --git a/frontend/src/views/settings/components/privacy/PrivacyPolicyCard.vue b/frontend/src/views/settings/components/privacy/PrivacyPolicyCard.vue new file mode 100644 index 000000000..8ad24f8f4 --- /dev/null +++ b/frontend/src/views/settings/components/privacy/PrivacyPolicyCard.vue @@ -0,0 +1,116 @@ + + + diff --git a/frontend/src/views/settings/components/privacy/RuleEditor.vue b/frontend/src/views/settings/components/privacy/RuleEditor.vue new file mode 100644 index 000000000..dfad5bb2a --- /dev/null +++ b/frontend/src/views/settings/components/privacy/RuleEditor.vue @@ -0,0 +1,208 @@ + + + diff --git a/frontend/src/views/settings/components/privacy/RulesTable.vue b/frontend/src/views/settings/components/privacy/RulesTable.vue new file mode 100644 index 000000000..4e7cbbf57 --- /dev/null +++ b/frontend/src/views/settings/components/privacy/RulesTable.vue @@ -0,0 +1,82 @@ + + + diff --git a/frontend/src/views/settings/components/privacy/presets.ts b/frontend/src/views/settings/components/privacy/presets.ts new file mode 100644 index 000000000..d2e7ed150 --- /dev/null +++ b/frontend/src/views/settings/components/privacy/presets.ts @@ -0,0 +1,120 @@ +import type { PrivacyAction, PrivacyRule, PrivacySample, PrivacyScope } from '@/api/modules/settings/privacy' + +export const scopes: { value: PrivacyScope, label: string }[] = [ + { value: 'turn_metadata', label: '工作区附加信息' }, + { value: 'desktop_git_context', label: 'Desktop Git 信息' }, + { value: 'environment_text', label: '自动环境上下文' }, + { value: 'request_body', label: '指定请求字段' }, + { value: 'request_header', label: '指定请求头' }, +] +export const actions: { value: PrivacyAction, label: string }[] = [ + { value: 'regex_replace', label: '正则替换' }, + { value: 'set_value', label: '固定值' }, + { value: 'rename_key', label: '字段名替换' }, + { value: 'remove_field', label: '删除字段 / 数组项' }, +] +export const fieldsByScope: Partial> = { + turn_metadata: [ + { value: '', label: '自定义字段路径' }, + { value: '$.workspaces.*.associated_remote_urls.*', label: 'Git 远端地址' }, + { value: '$.workspaces', label: '工作区路径键' }, + { value: '$.workspaces.*.latest_git_commit_hash', label: 'Git 提交标识' }, + ], + desktop_git_context: [ + { value: '', label: '自定义字段路径' }, + { value: '$.remotes', label: 'Git 远端集合' }, + { value: '$.remotes[*].fetchUrl', label: 'Git 远端地址' }, + { value: '$.headCommitSha', label: 'Git 提交标识' }, + { value: '$.worktreeId', label: '工作区指纹' }, + { value: '$.appVersion', label: '应用版本' }, + { value: '$.appServerVersion', label: 'Core 版本' }, + ], +} + +export function newRule(): PrivacyRule { + return { id: Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join(''), name: '', enabled: false, scope: 'turn_metadata', selector: '$.workspaces.*.associated_remote_urls.*', action: 'regex_replace', pattern: '', replacement: '', value: '', replaceAll: true, caseInsensitive: false, multiLine: false } +} + +export const presets: { name: string, values: Partial }[] = [ + { name: '工作区仓库地址脱敏', values: { scope: 'turn_metadata', selector: '$.workspaces.*.associated_remote_urls.*', action: 'regex_replace', pattern: '(?s)^.+$', replacement: '[REDACTED]' } }, + { name: '工作区用户名替换', values: { scope: 'turn_metadata', selector: '$.workspaces', action: 'rename_key', pattern: String.raw`(?i)^((?:[a-z]:)?[/\\](?:home|users)[/\\])[^/\\]+`, replacement: `\${1}user` } }, + { name: '移除工作区提交标识', values: { scope: 'turn_metadata', selector: '$.workspaces.*.latest_git_commit_hash', action: 'remove_field', pattern: null } }, + // Desktop Git 的这些字段可能为 null,按字段移除,避免正则因类型不匹配撤销整条规则 + // remotes 本身也可能为 null,预设处理整个远端集合,单个地址仍可从常用字段选择 + { name: '移除 Desktop Git 远端', values: { scope: 'desktop_git_context', selector: '$.remotes', action: 'remove_field', pattern: null } }, + { name: '移除 Desktop 提交标识', values: { scope: 'desktop_git_context', selector: '$.headCommitSha', action: 'remove_field', pattern: null } }, + { name: '移除 Desktop 工作区指纹', values: { scope: 'desktop_git_context', selector: '$.worktreeId', action: 'remove_field', pattern: null } }, + { name: '移除 Desktop 应用版本', values: { scope: 'desktop_git_context', selector: '$.appVersion', action: 'remove_field', pattern: null } }, + { name: '移除 Desktop Core 版本', values: { scope: 'desktop_git_context', selector: '$.appServerVersion', action: 'remove_field', pattern: null } }, +] + +// 样本使用虚构仓库、路径与标识,字段形状对应官方 Core 和 Desktop Git 上下文 +const metadata = { workspaces: { '/home/alex/projects/demo': { associated_remote_urls: { origin: 'https://git.example.invalid/private-org/repo.git', public: 'git@git.example.invalid:public/repo.git' }, latest_git_commit_hash: 'a'.repeat(40), has_changes: true } }, request_kind: 'turn' } +const desktopGit = { + schemaVersion: 1, + phase: 'before_turn_request', + hostId: 'local', + sessionId: 'example-session', + threadId: 'example-thread', + clientUserMessageId: 'example-message', + appVersion: '26.1007.21434', + appServerVersion: '0.162.0-alpha.17.2', + status: 'partial', + capturedAt: '2026-10-09T12:00:00Z', + worktreeId: 'b'.repeat(40), + headCommitSha: 'a'.repeat(40), + branch: 'main', + detached: false, + upstream: { remoteName: 'origin', mergeRef: 'refs/heads/main', trackingRef: 'refs/remotes/origin/main' }, + remotes: [ + { name: 'origin', fetchUrl: 'https://git.example.invalid/private-org/repo.git' }, + { name: 'upstream', fetchUrl: 'ssh://git.example.invalid/public/repo.git' }, + { name: 'unavailable', fetchUrl: null, unavailableReason: 'unsafe_url' }, + ], + remotesOmitted: 0, +} + +function desktopContext(value: unknown): string { + const text = JSON.stringify(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') + return `${text}` +} + +function contextMessage(text: string) { + return { role: 'user', content: [{ type: 'input_text', text }] } +} + +export function sampleText(scope: PrivacyScope): string { + if (scope === 'turn_metadata') + return JSON.stringify(metadata, null, 2) + if (scope === 'desktop_git_context') + return JSON.stringify(desktopGit, null, 2) + if (scope === 'environment_text') + return '\n/home/alex/projects/demo\nAsia/Shanghai\n' + if (scope === 'request_header') + return 'example-client' + return JSON.stringify({ metadata: { workspace: '/home/alex/projects/demo' } }, null, 2) +} + +export function makeSample(scope: PrivacyScope, text: string, selector = ''): PrivacySample { + const sample: PrivacySample = { body: {}, headers: {}, turnMetadata: null } + if (scope === 'turn_metadata') { + sample.turnMetadata = JSON.stringify(JSON.parse(text)) + } + else if (scope === 'request_header') { + sample.headers[selector] = [text] + } + else if (scope === 'request_body') { + sample.body = JSON.parse(text) + } + else { + const context = scope === 'desktop_git_context' + ? desktopContext(JSON.parse(text)) + : text + sample.body = { input: [contextMessage(context)] } + } + return sample +} + +export function exampleRequest(): PrivacySample { + return { body: { input: [contextMessage(desktopContext(desktopGit))], client_metadata: { 'x-codex-turn-metadata': JSON.stringify(metadata) } }, headers: { 'x-codex-turn-metadata': [JSON.stringify(metadata)] }, turnMetadata: null } +} diff --git a/frontend/src/views/settings/composables/usePrivacyPreview.ts b/frontend/src/views/settings/composables/usePrivacyPreview.ts new file mode 100644 index 000000000..d121dea3c --- /dev/null +++ b/frontend/src/views/settings/composables/usePrivacyPreview.ts @@ -0,0 +1,28 @@ +import type { CodexPrivacyPolicy, PrivacyPreviewResult, PrivacySample } from '@/api/modules/settings/privacy' +import { shallowRef } from 'vue' +import { previewPrivacyPolicy } from '@/api/modules/settings/privacy' +import { useRequestState } from '@/composables/useRequestState' + +export function usePrivacyPreview() { + const state = useRequestState() + const result = shallowRef() + function invalidate() { + state.invalidate() + result.value = undefined + state.error.value = '' + } + async function run(policy: CodexPrivacyPolicy, sample: PrivacySample) { + const id = state.start() + result.value = undefined + try { + const response = await previewPrivacyPolicy({ policy, ...sample }, { signal: state.signal }) + if (!state.isCurrent(id)) + return undefined + result.value = response + return response + } + catch (cause) { state.fail(id, cause) } + finally { state.finish(id) } + } + return { loading: state.loading, error: state.error, result, run, invalidate } +} diff --git a/frontend/src/views/settings/composables/useSettingsForm.ts b/frontend/src/views/settings/composables/useSettingsForm.ts index 70818c705..e9453643c 100644 --- a/frontend/src/views/settings/composables/useSettingsForm.ts +++ b/frontend/src/views/settings/composables/useSettingsForm.ts @@ -1,5 +1,5 @@ import type { rotationOptions } from '../constants' -import type { AccountAffinity, SmartSchedulingConfig } from '@/api' +import type { AccountAffinity, CodexPrivacyPolicy, SmartSchedulingConfig } from '@/api' import type { ProviderRequestProfiles, ProviderRequestProfileUpdates } from '@/api/modules/settings/profiles' import { toast } from '@codex-proxy/ui' import { cloneDeep, isEqual } from 'es-toolkit' @@ -26,6 +26,7 @@ export function useSettingsForm() { configRevision: 0, smartScheduling: undefined as SmartSchedulingConfig | undefined, providerRequestProfiles: {} as ProviderRequestProfiles, + codexPrivacyPolicy: { enabled: false, onError: 'skip_rule', rules: [] } as CodexPrivacyPolicy, requestLocationEnabled: false, requestLocation: { country: '', region: '', city: '', timezone: '' }, refreshMarginSeconds: null as number | null, @@ -114,6 +115,7 @@ export function useSettingsForm() { } function applySettings(data: Awaited>) { + form.codexPrivacyPolicy = cloneDeep(data.codexPrivacyPolicy) form.configRevision = data.configRevision form.requestLocationEnabled = data.requestLocationEnabled form.requestLocation = { ...data.requestLocation } @@ -290,6 +292,7 @@ export function useSettingsForm() { savedSettings.form.providerRequestProfiles, form.providerRequestProfiles, ), + codexPrivacyPolicy: cloneDeep(form.codexPrivacyPolicy), requestLocationEnabled: form.requestLocationEnabled, requestLocation, modelMappings: mappingPayload(), diff --git a/frontend/src/views/settings/index.vue b/frontend/src/views/settings/index.vue index b8090ac56..62b8ce3fd 100644 --- a/frontend/src/views/settings/index.vue +++ b/frontend/src/views/settings/index.vue @@ -12,6 +12,7 @@ import SettingsBackupSection from './components/backup/SettingsBackupSection.vue import ClientProfileCard from './components/ClientProfileCard.vue' import ConcurrencyPolicyCard from './components/ConcurrencyPolicyCard.vue' import ModelAliasesCard from './components/ModelAliasesCard.vue' +import PrivacyPolicyCard from './components/privacy/PrivacyPolicyCard.vue' import RequestLocationCard from './components/RequestLocationCard.vue' import RotationStrategyCard from './components/RotationStrategyCard.vue' import SettingsAccessSection from './components/SettingsAccessSection.vue' @@ -192,6 +193,7 @@ watch(section, (value) => { :disabled="disabled" /> +

diff --git a/frontend/src/views/settings/pricing/PricingUnit.vue b/frontend/src/views/settings/pricing/PricingUnit.vue index 179574992..25f93e0e1 100644 --- a/frontend/src/views/settings/pricing/PricingUnit.vue +++ b/frontend/src/views/settings/pricing/PricingUnit.vue @@ -1,5 +1,5 @@