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 <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-10 00:41:25 +08:00
committed by GitHub
co-authored by Codex
parent bbb8278281
commit 78b2250faa
69 changed files with 3289 additions and 64 deletions
+1
View File
@@ -3289,6 +3289,7 @@ dependencies = [
"native-tls",
"openssl",
"openssl-sys",
"regex",
"reqwest",
"roxmltree",
"rustls",
@@ -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,
@@ -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<dyn gateway_core::settings::privacy::CompiledPrivacyPolicy>,
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()
@@ -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,
@@ -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<gateway_core::settings::privacy::PrivacyPreviewResult, AdminError> {
Err(AdminError::invalid("隐私预览不可用"))
}
async fn preview_pricing_sync(
&self,
) -> Result<crate::model::pricing::PricingSyncPreview, AdminError>;
@@ -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<gateway_core::settings::privacy::PrivacyPreviewResult, AdminError> {
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<crate::model::pricing::PricingSyncPreview, AdminError> {
@@ -342,6 +364,17 @@ impl SettingsService for DefaultSettingsService {
mut command: ReplaceRuntimeSettings,
) -> Result<RuntimeSettings, AdminError> {
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()
@@ -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,
@@ -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,
@@ -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,
@@ -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<String, serde_json::Value>),
_ => 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",
&[
@@ -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,
@@ -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::<S>),
)
.route(
"/api/admin/settings/privacy/preview",
post(preview_privacy::<S>),
)
.route("/api/admin/settings/update", post(update_settings::<S>))
.route(
"/api/admin/settings/client-downloads/codex-desktop/windows",
@@ -719,6 +724,7 @@ impl RuntimeSettingsFields {
self,
) -> Result<gateway_admin::model::settings::RuntimeSettingsValues, WireValidationError> {
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<gateway_admin::model::settings::RuntimeSettingsValues> 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<S>(
State(state): State<S>,
_auth: AdminAuth,
AdminJson(request): AdminJson<gateway_core::settings::privacy::PrivacyPreviewRequest>,
) -> Result<impl IntoResponse, AdminError>
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),
))
}
@@ -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,
@@ -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::<UpdateRuntimeSettingsRequest>(missing).is_err());
let mut unknown = update_body();
unknown["codexPrivacyPolicy"]["protectedFields"] = json!([]);
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(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,
@@ -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)
@@ -287,6 +287,7 @@ pub struct RequestAttemptContext {
request_profile: Option<crate::account::OpaqueProviderData>,
fast_mode: FastMode,
request_location: Option<crate::account::RequestLocation>,
privacy: Option<Arc<dyn crate::settings::privacy::CompiledPrivacyPolicy>>,
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<Arc<dyn crate::settings::privacy::CompiledPrivacyPolicy>>,
) -> 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<Arc<dyn crate::settings::privacy::CompiledPrivacyPolicy>> {
self.request.privacy.clone()
}
#[must_use]
pub const fn request_location(&self) -> Option<&crate::account::RequestLocation> {
self.request.request_location.as_ref()
@@ -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<dyn crate::settings::privacy::CompiledPrivacyPolicy>,
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<dyn crate::settings::privacy::CompiledPrivacyPolicy>,
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)
}
}
+2 -1
View File
@@ -472,7 +472,8 @@ pub fn prepare_control_plane(
providers: ProviderRegistry,
extensions: Option<Arc<dyn routing::extensions::ExtensionPreparationPort>>,
) -> 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);
}
@@ -563,6 +563,7 @@ impl ProviderCandidate {
pub struct RoutingPlan {
pricing: Arc<crate::metering::PricingOverrides>,
request_location: Option<crate::account::RequestLocation>,
privacy: Option<Arc<dyn crate::settings::privacy::CompiledPrivacyPolicy>>,
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<Arc<dyn crate::settings::privacy::CompiledPrivacyPolicy>> {
self.privacy.clone()
}
/// 本次请求冻结的全局位置,重试时沿用同一份配置
#[must_use]
pub const fn request_location(&self) -> Option<&crate::account::RequestLocation> {
@@ -219,6 +219,7 @@ pub enum RuntimeSnapshotCompileError {
#[derive(Clone)]
pub struct RuntimeSnapshotCompiler {
store: Arc<dyn SnapshotStorePort>,
privacy_compiler: Option<Arc<dyn crate::settings::privacy::PrivacyPolicyCompiler>>,
catalogs: Arc<dyn ProviderCatalogPort>,
extensions: Option<Arc<dyn crate::routing::extensions::ExtensionPreparationPort>>,
}
@@ -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<dyn crate::settings::privacy::PrivacyPolicyCompiler>,
) -> Self {
self.privacy_compiler = Some(compiler);
self
}
pub(crate) fn store(&self) -> Arc<dyn SnapshotStorePort> {
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<ProviderKind>,
previous: Option<&RuntimeSnapshot>,
privacy_compiler: Option<Arc<dyn crate::settings::privacy::PrivacyPolicyCompiler>>,
) -> Result<RuntimeSnapshot, RuntimeSnapshotCompileError> {
// 只有成功取得的完整目录能证明模型缺项;发现型目录和查询失败均交由上游验证
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<CompiledSettings>,
privacy_compiler: Option<Arc<dyn crate::settings::privacy::PrivacyPolicyCompiler>>,
extensions: Option<crate::routing::extensions::ExtensionSetReference>,
revision: ConfigRevision,
providers: Arc<BTreeSet<ProviderKind>>,
@@ -559,8 +579,12 @@ impl RuntimeSnapshot {
&self,
values: &SettingsValues,
) -> Result<Self, RuntimeSnapshotCompileError> {
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<ProviderKind>,
provider_models: Vec<ProviderModel>,
client_policies: Vec<ClientPolicy>,
) -> Result<Self, RoutingError> {
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<ProviderKind>,
provider_models: Vec<ProviderModel>,
client_policies: Vec<ClientPolicy>,
privacy_compiler: Option<Arc<dyn crate::settings::privacy::PrivacyPolicyCompiler>>,
) -> Result<Self, RoutingError> {
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)
@@ -13,6 +13,7 @@ use crate::{
#[derive(Debug, Clone)]
pub(crate) struct CompiledSettings {
pub(crate) values: SettingsValues,
pub(crate) privacy: Option<std::sync::Arc<dyn super::privacy::CompiledPrivacyPolicy>>,
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<Self, InvalidSettings> {
pub(crate) fn new(
settings: SettingsValues,
compiler: Option<&dyn super::privacy::PrivacyPolicyCompiler>,
previous: Option<&Self>,
) -> Result<Self, InvalidSettings> {
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,
})
}
}
@@ -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,
@@ -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<PrivacyRule>,
}
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<String>,
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<String>,
}
/// 已编译策略随快照冻结,调用方提供独立的本次出站副本
pub trait CompiledPrivacyPolicy: fmt::Debug + Send + Sync {
fn apply(
&self,
body: &mut Value,
headers: &mut http::HeaderMap,
turn_metadata: &mut Option<String>,
cancelled: &dyn Fn() -> bool,
) -> Result<Vec<PrivacyRuleOutcome>, PrivacyError>;
}
pub trait PrivacyPolicyCompiler: fmt::Debug + Send + Sync {
fn compile(
&self,
policy: &CodexPrivacyPolicy,
) -> Result<Arc<dyn CompiledPrivacyPolicy>, 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<String, Vec<String>>,
pub turn_metadata: Option<String>,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrivacyPreviewResult {
pub body: Value,
pub headers: std::collections::BTreeMap<String, Vec<String>>,
pub turn_metadata: Option<String>,
pub outcomes: Vec<PrivacyRuleOutcome>,
}
@@ -10,6 +10,7 @@ use crate::identity::ProviderKind;
pub struct SettingsValues {
pub(crate) pricing: Arc<crate::metering::PricingOverrides>,
pub(crate) request_profiles: Arc<BTreeMap<ProviderKind, crate::account::OpaqueProviderData>>,
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,
@@ -18,6 +18,7 @@ use gateway_core::routing::{
UpstreamModelId,
};
mod privacy;
mod request_settings;
mod snapshot;
@@ -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<Arc<dyn CompiledPrivacyPolicy>, 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<String>,
_: &dyn Fn() -> bool,
) -> Result<Vec<PrivacyRuleOutcome>, 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<Arc<dyn CompiledPrivacyPolicy>> {
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());
}
}
@@ -16,8 +16,80 @@ pub type RotationStrategy = String;
pub type AccountAffinity = String;
pub type PricingOverrides = BTreeMap<String, BTreeMap<String, ModelPriceOverride>>;
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CodexPrivacyPolicy {
pub enabled: bool,
pub on_error: PrivacyFailureMode,
pub rules: Vec<PrivacyRule>,
}
#[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<String>,
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<String>,
}
#[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<String, Vec<String>>,
pub turn_metadata: Option<String>,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrivacyPreviewResult {
pub body: serde_json::Value,
pub headers: BTreeMap<String, Vec<String>>,
pub turn_metadata: Option<String>,
pub outcomes: Vec<PrivacyRuleOutcome>,
}
#[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<RuntimeSettings> 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,
@@ -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(),
@@ -150,7 +150,7 @@ impl RuntimeSettingsRepository for PgRuntimeSettingsRepository {
pub(crate) async fn load_runtime_settings_from_pool(pool: &PgPool) -> StoreResult<RuntimeSettings> {
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<RuntimeSettings> {
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<gateway_core::account::SmartSchedulingConfig>,
rotation_strategy: String,
codex_privacy_policy_json:
sqlx::types::Json<gateway_core::settings::privacy::CodexPrivacyPolicy>,
request_location_enabled: bool,
request_location_json: sqlx::types::Json<gateway_core::account::RequestLocation>,
model_mappings_json: sqlx::types::Json<BTreeMap<String, String>>,
@@ -579,6 +583,7 @@ fn runtime_settings_from_row(row: RuntimeSettingsRow) -> StoreResult<RuntimeSett
max_concurrent_per_account: to_u32(row.max_concurrent_per_account)?,
request_interval_ms: to_u64(row.request_interval_ms)?,
smart_scheduling: row.smart_scheduling_json.0,
codex_privacy_policy: row.codex_privacy_policy_json.0,
request_location_enabled: row.request_location_enabled,
request_location: row
.request_location_json
@@ -226,6 +226,8 @@ struct SnapshotSettingsRow {
max_account_rotations: i64,
openai_session_affinity_ttl_hours: i64,
request_location_json: sqlx::types::Json<gateway_core::account::RequestLocation>,
codex_privacy_policy_json:
sqlx::types::Json<gateway_core::settings::privacy::CodexPrivacyPolicy>,
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)?,
@@ -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
@@ -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。
@@ -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<dyn gateway_core::settings::privacy::CompiledPrivacyPolicy>,
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,
@@ -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,
@@ -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<ProviderDiagnostic> {
"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",
@@ -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<dyn gateway_core::settings::privacy::CompiledPrivacyPolicy>,
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,
@@ -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<dyn gateway_core::settings::privacy::CompiledPrivacyPolicy>,
gateway_core::lifecycle::CancellationToken,
)>,
pub(super) timezone: gateway_core::time::DeploymentTimeZone,
pub(super) response_control: Option<gateway_core::engine::response_control::ResponseControl>,
pub(super) connection_budget: Option<gateway_core::engine::connection::ConnectionBudget>,
@@ -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
@@ -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<CodexBackendStreamingResponse> {
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
}
@@ -72,3 +72,5 @@ pub use self::{
CodexWebSocketPool, CodexWebSocketPoolConfig, CodexWebSocketPoolKey, WebSocketPoolDecision,
},
};
pub mod privacy;
@@ -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<String>,
budget: &mut Budget<'_>,
) -> Result<usize, &'static str> {
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::<Result<Vec<_>, _>>()?;
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<usize, &'static str> {
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<usize, &'static str> {
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<usize, &'static str> {
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!("</{tag}>"));
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<usize, &'static str> {
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("</").ok_or("Desktop Git 包裹无效")?;
let encoded = serde_json::to_string(&value).map_err(|_| "Desktop Git JSON 无法编码")?;
let escaped = encoded
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;");
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<usize, &'static str> {
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<roxmltree::Document<'_>, &'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<Vec<(String, usize)>, &'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)
}
@@ -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<rule::CompiledRule>,
}
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<Arc<dyn CompiledPrivacyPolicy>, 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::<Result<Vec<_>, _>>()?;
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<String>,
cancelled: &dyn Fn() -> bool,
) -> Result<Vec<PrivacyRuleOutcome>, 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<PrivacyPreviewResult, PrivacyError> {
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::<usize>())
.sum::<usize>()
+ 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<usize> {
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<Arc<dyn CompiledPrivacyPolicy>>,
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(())
}
}
@@ -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<selector::Segment>,
regex: Option<Regex>,
}
impl CompiledRule {
pub(super) fn compile(rule: &PrivacyRule) -> Result<Self, &'static str> {
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<usize, &'static str> {
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<bool, &'static str> {
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::<usize>().map_or_else(
|_| regex.capture_names().flatten().any(|group| group == name),
|index| index < regex.captures_len(),
);
if !valid {
return Err("替换内容引用了不存在的捕获组");
}
rest = tail;
}
Ok(())
}
@@ -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<Vec<Segment>, &'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::<String>();
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<bool, &'static str>,
) -> Result<bool, &'static str> {
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)
}
@@ -612,6 +612,17 @@ fn metadata_string(request: &CodexResponsesRequest, key: &str) -> Option<String>
.map(ToOwned::to_owned)
}
pub(crate) fn serialize_ascii_turn_metadata(value: &Value) -> Option<String> {
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;
@@ -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;
@@ -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::<Value>(&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::<Value>(&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();
}
@@ -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;
@@ -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<PrivacyRule>) -> CodexPrivacyPolicy {
CodexPrivacyPolicy {
enabled: true,
on_error: PrivacyFailureMode::RejectRequest,
rules,
}
}
fn sample(rules: Vec<PrivacyRule>, 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::<Value>(
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 = "<external_codex_git_state><environment_context><git source=\"app\" trust=\"untrusted\">{\"workspaces\":{\"/private\":{}},\"note\":\"a &amp; b\"}</git></environment_context></external_codex_git_state>";
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 &amp; 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":"<codex_apps_client_time_context><timezone>Asia/Shanghai</timezone></codex_apps_client_time_context>"}]}]});
assert!(
preview(sample(vec![replacement], body))
.unwrap()
.body
.to_string()
.contains("<timezone>UTC</timezone>")
);
}
#[test]
fn privacy_validates_regex_paths_captures_and_execution_limits() {
let mut replace = rule(
PrivacyScope::RequestBody,
"$[\"a.b\"][0]",
PrivacyAction::RegexReplace,
);
replace.pattern = Some("(?P<word>a)".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 = "<external_codex_git_state><environment_context><git source=\"a>b\"><![CDATA[{\"workspaces\":{},\"note\":\"a & b\"}]]></git></environment_context></external_codex_git_state>";
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::<Value>(git.text().unwrap()).unwrap(),
json!({"note":"a & b"})
);
let nested = format!(
"<environment_context>{}value{}</environment_context>",
"<n>".repeat(100),
"</n>".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");
}
+1
View File
@@ -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
@@ -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']
);
+43
View File
@@ -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` 表示不限制。
+6
View File
@@ -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 与重试共用。
@@ -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<string, string>
@@ -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<string, string[]>
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<PrivacyPreviewResult>({
url: '/api/admin/settings/privacy/preview',
method: 'POST',
data,
...options,
})
}
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountAffinity } from '@/api'
import { BaseCard, BaseForm, BaseFormItem, BaseInput, BasePopover, BaseSegmented } from '@codex-proxy/ui'
import { BaseCard, BaseForm, BaseFormItem, BaseIconButton, BaseInput, BasePopover, BaseSegmented } from '@codex-proxy/ui'
import { CircleAlert } from '@lucide/vue'
const props = defineProps<{ disabled: boolean }>()
@@ -17,9 +17,9 @@ const affinityOptions = [{ label: '宽松', value: 'relaxed' }, { label: '优先
<template #label-extra>
<BasePopover class="-my-1" trigger="hover-click" placement="top-start">
<template #trigger="{ open }">
<button type="button" aria-label="账号亲和说明" :aria-expanded="open" class="inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-cp-sm border-0 bg-transparent p-0 text-cp-text-tertiary outline-none transition-colors hover:text-cp-text focus-visible:ring-2 focus-visible:ring-cp-control-outline motion-reduce:transition-none">
<BaseIconButton label="账号亲和说明" :title="undefined" :aria-expanded="open" class="size-6! hover:bg-transparent! active:bg-transparent!">
<CircleAlert class="size-3.5" aria-hidden="true" />
</button>
</BaseIconButton>
</template>
<div class="max-w-72 space-y-2 px-3 py-2 text-cp-sm leading-relaxed text-cp-text-secondary">
<p>宽松:会话内请求直接按调度策略选号,不优先主账号</p>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseCard, BaseCheckbox, BaseForm, BaseFormItem, BaseInput, BasePopover, BaseSwitch } from '@codex-proxy/ui'
import { BaseCard, BaseCheckbox, BaseForm, BaseFormItem, BaseIconButton, BaseInput, BasePopover, BaseSwitch } from '@codex-proxy/ui'
import { Activity, CircleAlert, Gauge, Snowflake, Timer } from '@lucide/vue'
import { useId } from 'vue'
@@ -125,15 +125,15 @@ const adaptiveConcurrencyHintId = useId()
/>
<BasePopover class="-my-1" trigger="hover-click" placement="top-start" :hover-delay="240">
<template #trigger="{ open }">
<button
type="button"
class="inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-cp-sm border-0 bg-transparent p-0 text-cp-text-tertiary outline-none transition-colors hover:text-cp-text focus-visible:ring-2 focus-visible:ring-cp-control-outline motion-reduce:transition-none"
aria-label="自适应并发下调说明"
<BaseIconButton
label="自适应并发下调说明"
:title="undefined"
class="size-6! hover:bg-transparent! active:bg-transparent!"
:aria-expanded="open"
:aria-describedby="open ? adaptiveConcurrencyHintId : undefined"
>
<CircleAlert class="size-3.5" aria-hidden="true" />
</button>
</BaseIconButton>
</template>
<p :id="adaptiveConcurrencyHintId" role="tooltip" class="m-0 max-w-72 px-3 py-2 text-cp-sm leading-relaxed text-cp-text-secondary">
修改账号并发上限,恢复后不自动调高
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseCard, BaseForm, BaseFormItem, BaseInput, BasePopover } from '@codex-proxy/ui'
import { BaseCard, BaseForm, BaseFormItem, BaseIconButton, BaseInput, BasePopover } from '@codex-proxy/ui'
import { CircleAlert, Gauge, Timer } from '@lucide/vue'
@@ -62,9 +62,9 @@ const openaiGuardianReservedConcurrency = defineModel<string>('openaiGuardianRes
<template #label-extra>
<BasePopover class="-my-1" trigger="hover-click" placement="top-start">
<template #trigger="{ open }">
<button type="button" aria-label="自动审批独立并发说明" :aria-expanded="open" class="inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-cp-sm border-0 bg-transparent p-0 text-cp-text-tertiary outline-none transition-colors hover:text-cp-text focus-visible:ring-2 focus-visible:ring-cp-control-outline motion-reduce:transition-none">
<BaseIconButton label="自动审批独立并发说明" :title="undefined" :aria-expanded="open" class="size-6! hover:bg-transparent! active:bg-transparent!">
<CircleAlert class="size-3.5" aria-hidden="true" />
</button>
</BaseIconButton>
</template>
<div class="max-w-72 space-y-2 px-3 py-2 text-cp-sm leading-relaxed text-cp-text-secondary">
<p>仅对 OpenAI 账号生效,普通并发为 10、审批名额为 3 时,两类请求分别最多运行 10 个和 3 个,互不占用名额</p>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { SmartSchedulingConfig } from '@/api'
import { BaseButton, BaseFormItem, BaseInput, BaseModal, BasePopover, BaseSwitch } from '@codex-proxy/ui'
import { BaseButton, BaseFormItem, BaseIconButton, BaseInput, BaseModal, BasePopover, BaseSwitch } from '@codex-proxy/ui'
import { CircleAlert, CircleHelp } from '@lucide/vue'
import { computed, reactive, shallowRef, useId, watch } from 'vue'
@@ -75,9 +75,9 @@ function confirm() {
{{ field.label }}
<BasePopover trigger="hover-click" placement="top-start">
<template #trigger="{ open: helpOpen }">
<button type="button" class="inline-flex size-6 cursor-pointer items-center justify-center rounded-cp-sm border-0 bg-transparent p-0 text-cp-text-tertiary outline-none hover:text-cp-text focus-visible:ring-2 focus-visible:ring-cp-control-outline" :aria-label="`${field.label}说明`" :aria-expanded="helpOpen">
<BaseIconButton :label="`${field.label}说明`" :title="undefined" :aria-expanded="helpOpen" class="size-6! hover:bg-transparent! active:bg-transparent!">
<CircleHelp class="size-3.5" />
</button>
</BaseIconButton>
</template>
<p class="m-0 max-w-64 px-3 py-2 text-cp-sm leading-relaxed">{{ field.help }}</p>
</BasePopover>
@@ -96,9 +96,9 @@ function confirm() {
回切行为
<BasePopover trigger="hover-click" placement="top-start">
<template #trigger="{ open: helpOpen }">
<button type="button" class="inline-flex size-6 cursor-pointer items-center justify-center rounded-cp-sm border-0 bg-transparent p-0 text-cp-text-tertiary outline-none hover:text-cp-text focus-visible:ring-2 focus-visible:ring-cp-control-outline" aria-label="回切行为说明" :aria-expanded="helpOpen">
<BaseIconButton label="回切行为说明" :title="undefined" :aria-expanded="helpOpen" class="size-6! hover:bg-transparent! active:bg-transparent!">
<CircleAlert class="size-3.5" />
</button>
</BaseIconButton>
</template>
<p class="m-0 max-w-72 px-3 py-2 text-cp-sm leading-relaxed">
高权重账号恢复可用后,后续允许重新选号的请求优先回切<br>原生续写仍绑定原账号,同权重账号保留已有会话亲和
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseCard, BaseForm, BaseFormItem, BaseInput } from '@codex-proxy/ui'
import { BaseCard, BaseForm, BaseFormItem, BaseIconButton, BaseInput } from '@codex-proxy/ui'
import { CircleHelp, MonitorUp, TerminalSquare } from '@lucide/vue'
@@ -31,14 +31,14 @@ const minCodexCliVersion = defineModel<string>('minCodexCliVersion', { required:
<template #title>
<span class="inline-flex items-center gap-1.5">
<span>客户端版本限制</span>
<button
type="button"
class="inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-cp-sm border-0 bg-transparent p-0 text-cp-text-tertiary outline-none transition-colors hover:text-cp-text focus-visible:ring-2 focus-visible:ring-cp-control-outline motion-reduce:transition-none"
aria-label="查看安装与升级说明"
<BaseIconButton
label="查看安装与升级说明"
:title="undefined"
class="size-6! hover:bg-transparent! active:bg-transparent!"
@click="emit('help')"
>
<CircleHelp class="size-3.5" aria-hidden="true" />
</button>
</BaseIconButton>
</span>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { CodexPrivacyPolicy, PrivacySample } from '@/api/modules/settings/privacy'
import { BaseButton, BaseFormItem, BaseModal, BaseTextarea } from '@codex-proxy/ui'
import { computed, shallowRef, watch } from 'vue'
import { usePrivacyPreview } from '../../composables/usePrivacyPreview'
import { exampleRequest } from './presets'
const props = defineProps<{ policy: CodexPrivacyPolicy }>()
const open = defineModel<boolean>({ required: true })
const sample = shallowRef('')
const localError = shallowRef('')
const preview = usePrivacyPreview()
const { loading, error, result } = preview
const output = computed(() => result.value ? JSON.stringify({ body: result.value.body, headers: result.value.headers, turnMetadata: result.value.turnMetadata }, null, 2) : '')
watch(open, (value) => {
preview.invalidate()
sample.value = value ? JSON.stringify(exampleRequest(), null, 2) : ''
localError.value = ''
})
watch([sample, () => props.policy], () => preview.invalidate(), { deep: true, flush: 'sync' })
async function test() {
localError.value = ''
try {
const parsed = JSON.parse(sample.value) as PrivacySample
await preview.run({ ...props.policy, enabled: true }, parsed)
}
catch { localError.value = '请输入有效的请求样本 JSON' }
}
</script>
<template>
<BaseModal v-model="open" title="测试规则" description="按草稿顺序执行已启用的规则,样本仅用于本次测试" size="lg">
<div class="grid gap-3">
<div class="grid min-w-0 gap-3 sm:grid-cols-2">
<BaseFormItem label="请求样本">
<BaseTextarea v-model="sample" aria-label="请求样本" placeholder="粘贴包含 body、headers、turnMetadata 的请求 JSON" resize="none" maxlength="262144" size="sm" class="font-mono [&_textarea]:block [&_textarea]:h-64" spellcheck="false" />
</BaseFormItem>
<div class="grid min-w-0 content-start gap-2">
<span class="flex min-h-4 items-center text-cp leading-none font-medium text-cp-text-secondary">最终结果</span><pre class="cp-scrollbar m-0 h-64 overflow-auto rounded-cp bg-cp-fill-quaternary p-3 font-mono text-cp-xs break-all whitespace-pre-wrap">{{ output || '点击测试查看结果' }}</pre>
</div>
</div>
<div v-if="result" class="grid gap-2">
<div v-for="(outcome, index) in result.outcomes" :key="outcome.ruleId" class="flex flex-wrap justify-between gap-2 text-cp-sm">
<span>{{ index + 1 }} · {{ policy.rules.find(rule => rule.id === outcome.ruleId)?.name }}</span>
<span :class="outcome.status === 'skipped' ? 'text-cp-warning-text' : 'text-cp-text-secondary'">{{ outcome.reason || (outcome.status === 'disabled' ? '未启用' : `命中 ${outcome.matches} 处`) }}</span>
</div>
</div>
<p v-if="localError || error" role="alert" class="m-0 text-cp-sm text-cp-error-text">
{{ localError || error }}
</p>
</div>
<template #footer>
<BaseButton size="sm" variant="secondary" @click="open = false">
关闭
</BaseButton><BaseButton size="sm" variant="primary" :loading="loading" @click="test">
测试规则
</BaseButton>
</template>
</BaseModal>
</template>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import type { CodexPrivacyPolicy, PrivacyRule } from '@/api/modules/settings/privacy'
import { BaseButton, BaseCard, BaseIconButton, BaseMenuItem, BasePopover, BaseSelect, BaseSwitch } from '@codex-proxy/ui'
import { ChevronDown, CircleHelp, Play, Plus } from '@lucide/vue'
import { shallowRef } from 'vue'
import PolicyPreview from './PolicyPreview.vue'
import { newRule, presets } from './presets'
import RuleEditor from './RuleEditor.vue'
import RulesTable from './RulesTable.vue'
defineProps<{ disabled: boolean }>()
const model = defineModel<CodexPrivacyPolicy>({ required: true })
const editorOpen = shallowRef(false)
const selected = shallowRef<PrivacyRule | null>(null)
const isNew = shallowRef(false)
const presetOpen = shallowRef(false)
const testOpen = shallowRef(false)
const failureOptions: { value: CodexPrivacyPolicy['onError'], label: string }[] = [{ value: 'skip_rule', label: '跳过当前规则' }, { value: 'reject_request', label: '阻止请求发送' }]
function edit(rule: PrivacyRule, create = false) {
selected.value = rule
isNew.value = create
editorOpen.value = true
presetOpen.value = false
}
function apply(rule: PrivacyRule) {
model.value = { ...model.value, rules: isNew.value ? [...model.value.rules, rule] : model.value.rules.map(item => item.id === rule.id ? rule : item) }
}
function add(values: Partial<PrivacyRule> = {}) {
edit({ ...newRule(), ...values }, true)
}
function toggle(rule: PrivacyRule, enabled: boolean) {
model.value = { ...model.value, rules: model.value.rules.map(item => item.id === rule.id ? { ...item, enabled } : item) }
}
function duplicate(rule: PrivacyRule) {
if (model.value.rules.length >= 32)
return
model.value = { ...model.value, rules: [...model.value.rules, { ...rule, id: newRule().id, name: `${rule.name.slice(0, 60)}(副本)` }] }
}
function remove(rule: PrivacyRule) {
model.value = { ...model.value, rules: model.value.rules.filter(item => item.id !== rule.id) }
}
function move(index: number, step: number) {
const rules = [...model.value.rules]
const target = index + step
if (!rules[index] || !rules[target]) {
return
}
const selectedRule = rules[index]!
rules[index] = rules[target]!
rules[target] = selectedRule
model.value = { ...model.value, rules }
}
</script>
<template>
<BaseCard id="privacy-policy" title="Codex 隐私策略" description="按自定义规则替换或移除请求中的隐私信息">
<template #actions>
<BaseSwitch v-model="model.enabled" label="启用请求规则" active-text="启用" inactive-text="关闭" inline-prompt :width="56" :disabled="disabled" />
</template>
<template #body>
<div class="grid gap-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<BaseButton size="sm" variant="secondary" :disabled="disabled || model.rules.length >= 32" @click="add()">
<template #icon>
<Plus class="size-3.5" />
</template>添加规则
</BaseButton>
<BasePopover v-model="presetOpen" placement="bottom-start">
<template #trigger>
<BaseButton size="sm" variant="ghost" :disabled="disabled || model.rules.length >= 32">
从预设添加<ChevronDown class="ml-1 size-3" />
</BaseButton>
</template>
<div class="w-48 p-1.5">
<BaseMenuItem v-for="preset in presets" :key="preset.name" @click="add({ ...preset.values, name: preset.name })">
{{ preset.name }}
</BaseMenuItem>
</div>
</BasePopover>
</div>
<BaseButton size="sm" variant="ghost" :disabled="disabled" @click="testOpen = true">
<template #icon>
<Play class="size-3.5" />
</template>测试规则
</BaseButton>
</div>
<RulesTable :rules="model.rules" :disabled="disabled" @edit="edit($event)" @toggle="toggle" @duplicate="duplicate" @remove="remove" @move="move" />
<div v-if="model.rules.length" class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<span class="text-cp-xs text-cp-text-quaternary">按列表顺序执行</span>
<div class="flex items-center gap-2">
<span class="text-cp-xs text-cp-text-secondary">执行失败</span>
<BaseSelect v-model="model.onError" size="sm" class="w-34" :options="failureOptions" aria-label="规则执行失败" :disabled="disabled" />
<BasePopover trigger="hover-click" placement="top-end">
<template #trigger="{ open: helpOpen }">
<BaseIconButton size="sm" variant="ghost" label="规则执行说明" :title="undefined" class="hover:bg-transparent! active:bg-transparent!" :aria-expanded="helpOpen">
<CircleHelp class="size-3.5" />
</BaseIconButton>
</template>
<div class="max-w-72 space-y-2 p-3 text-cp-sm text-cp-text-secondary">
<p class="m-0">
跳过时撤销本条修改并继续执行,未完成本条脱敏
</p><p class="m-0">
允许改写选定字段,认证、会话与工具行为的影响由配置者负责
</p>
</div>
</BasePopover>
</div>
</div>
</div>
</template>
</BaseCard>
<RuleEditor v-model="editorOpen" :rule="selected" :is-new="isNew" :disabled="disabled" @apply="apply" />
<PolicyPreview v-model="testOpen" :policy="model" />
</template>
@@ -0,0 +1,208 @@
<script setup lang="ts">
import type { PrivacyRule } from '@/api/modules/settings/privacy'
import { BaseButton, BaseCheckbox, BaseForm, BaseFormItem, BaseIconButton, BaseInput, BaseModal, BasePopover, BaseSelect, BaseTextarea } from '@codex-proxy/ui'
import { CircleHelp, Play } from '@lucide/vue'
import { cloneDeep } from 'es-toolkit'
import { computed, ref, shallowRef, useId, watch } from 'vue'
import { usePrivacyPreview } from '../../composables/usePrivacyPreview'
import { actions, fieldsByScope, makeSample, newRule, sampleText, scopes } from './presets'
const props = defineProps<{ rule: PrivacyRule | null, isNew: boolean, disabled: boolean }>()
const emit = defineEmits<{ apply: [rule: PrivacyRule] }>()
const open = defineModel<boolean>({ required: true })
const fieldPresetId = useId()
const draft = ref<PrivacyRule>(newRule())
const sample = shallowRef('')
const fixedValue = shallowRef('""')
const localError = shallowRef('')
const preview = usePrivacyPreview()
const { loading, error, result } = preview
const usesPattern = computed(() => ['regex_replace', 'rename_key'].includes(draft.value.action) || (draft.value.action === 'remove_field' && draft.value.pattern !== null))
const pattern = computed({ get: () => draft.value.pattern ?? '', set: (value: string) => {
draft.value.pattern = value
} })
const conditional = computed({ get: () => draft.value.pattern !== null, set: (value: boolean) => {
draft.value.pattern = value ? '' : null
} })
const fields = computed(() => fieldsByScope[draft.value.scope] ?? [])
const fieldChoice = computed({ get: () => fields.value.some(field => field.value === draft.value.selector) ? draft.value.selector : '', set: (value: string) => {
draft.value.selector = value
} })
const selectorPlaceholder = computed(() => ({
turn_metadata: '例如:$.workspaces.*.associated_remote_urls.*',
desktop_git_context: '例如:$.remotes[*].fetchUrl',
environment_text: '填写 $,匹配整段环境文本',
request_body: '例如:$.metadata.workspace',
request_header: '例如:x-client-name',
})[draft.value.scope])
const processed = computed(() => {
if (!result.value)
return ''
if (draft.value.scope === 'turn_metadata')
return JSON.stringify(JSON.parse(result.value.turnMetadata ?? '{}'), null, 2)
if (draft.value.scope === 'request_header')
return JSON.stringify(result.value.headers, null, 2)
return JSON.stringify(result.value.body, null, 2)
})
const outcome = computed(() => result.value?.outcomes[0])
watch(open, (value) => {
preview.invalidate()
localError.value = ''
if (value) {
draft.value = cloneDeep(props.rule ?? newRule())
fixedValue.value = JSON.stringify(draft.value.value)
sample.value = sampleText(draft.value.scope)
}
else {
sample.value = ''
}
})
watch(() => draft.value.scope, (scope) => {
sample.value = sampleText(scope)
})
watch(() => draft.value.action, (action) => {
if (action === 'set_value')
draft.value.pattern = null
else if (action !== 'remove_field' && draft.value.pattern === null)
draft.value.pattern = ''
})
watch([draft, sample, fixedValue], () => {
preview.invalidate()
localError.value = ''
}, { deep: true, flush: 'sync' })
function preparedRule() {
const rule = cloneDeep(draft.value)
if (!rule.name.trim() || !rule.selector.trim())
throw new Error('请填写规则名称和字段路径')
if (rule.action === 'set_value') {
try {
rule.value = JSON.parse(fixedValue.value)
}
catch { throw new Error('固定值应为 JSON,例如 "别名"、false 或 123') }
}
return rule
}
async function test() {
try {
const rule = preparedRule()
const value = makeSample(rule.scope, sample.value, rule.selector)
await preview.run({ enabled: true, onError: 'skip_rule', rules: [{ ...rule, enabled: true }] }, value)
}
catch (cause) { localError.value = cause instanceof Error ? cause.message : '样本格式无效' }
}
async function apply() {
if (props.disabled)
return
try {
const rule = preparedRule()
const validated = await preview.run({ enabled: false, onError: 'skip_rule', rules: [rule] }, { body: {}, headers: {}, turnMetadata: null })
if (!validated || !open.value)
return
emit('apply', rule)
open.value = false
}
catch (cause) { localError.value = cause instanceof Error ? cause.message : '规则格式无效' }
}
</script>
<template>
<BaseModal v-model="open" :title="isNew ? '添加规则' : '编辑规则'" size="md-wide">
<div class="grid gap-4">
<BaseForm class="sm:grid-cols-2">
<BaseFormItem class="sm:col-span-2" label="规则名称" required>
<BaseInput v-model="draft.name" placeholder="例如:工作区仓库地址脱敏" aria-label="规则名称" maxlength="64" />
</BaseFormItem>
<BaseFormItem label="作用范围">
<BaseSelect v-model="draft.scope" class="w-full" :options="scopes" aria-label="作用范围" />
</BaseFormItem>
<BaseFormItem label="处理方式">
<BaseSelect v-model="draft.action" class="w-full" :options="actions" aria-label="处理方式" />
</BaseFormItem>
<BaseFormItem class="sm:col-span-2" :label="draft.scope === 'request_header' ? '请求头名称' : '字段路径'" required>
<template #label-extra>
<BasePopover trigger="hover-click">
<template #trigger="{ open: helpOpen }">
<BaseIconButton size="sm" variant="ghost" label="字段路径语法" :title="undefined" class="-my-2 hover:bg-transparent! active:bg-transparent!" :aria-expanded="helpOpen">
<CircleHelp class="size-3.5" />
</BaseIconButton>
</template>
<div class="max-w-72 p-3 text-cp-sm text-cp-text-secondary">
支持对象成员、双引号键名、数组索引和 * 通配,例如 $["workspaces"]["/home/alex/demo"]<br>环境文本使用 $,请求头直接填写名称
</div>
</BasePopover>
</template>
<div class="flex flex-wrap items-center gap-2 sm:flex-nowrap">
<BaseInput v-model="draft.selector" :placeholder="selectorPlaceholder" aria-label="字段路径" maxlength="1024" class="min-w-0 flex-1 font-mono" :class="fields.length ? 'basis-full sm:basis-auto' : undefined" spellcheck="false" />
<BaseSelect v-if="fields.length" :id="fieldPresetId" v-model="fieldChoice" class="min-w-0 flex-1 sm:w-40 sm:flex-none" :options="fields" aria-label="常用字段" />
</div>
</BaseFormItem>
<BaseCheckbox v-if="draft.action === 'remove_field'" v-model="conditional" class="sm:col-span-2" label="仅删除值匹配正则的字段或数组项" show-label />
<BaseFormItem v-if="usesPattern" :class="draft.action === 'remove_field' ? 'sm:col-span-2' : undefined" label="匹配表达式">
<BaseInput v-model="pattern" :placeholder="draft.action === 'rename_key' ? '例如:^/home/[^/]+/' : '例如:private-org'" aria-label="匹配表达式" maxlength="4096" spellcheck="false" class="font-mono" />
</BaseFormItem>
<BaseFormItem v-if="draft.action === 'regex_replace' || draft.action === 'rename_key'" label="替换内容">
<template #label-extra>
<BasePopover trigger="hover-click">
<template #trigger="{ open: helpOpen }">
<BaseIconButton size="sm" variant="ghost" label="正则语法说明" :title="undefined" class="-my-2 hover:bg-transparent! active:bg-transparent!" :aria-expanded="helpOpen">
<CircleHelp class="size-3.5" />
</BaseIconButton>
</template>
<div class="max-w-72 p-3 text-cp-sm text-cp-text-secondary">
${1} 或 ${name} 引用捕获组,$$ 表示字面 $<br>使用 Rust regex 语法,不支持前后顾和模式中的反向引用
</div>
</BasePopover>
</template>
<BaseInput v-model="draft.replacement" :placeholder="draft.action === 'rename_key' ? '例如:/home/user/' : '例如:team,留空则清除匹配内容'" aria-label="替换内容" maxlength="4096" spellcheck="false" class="font-mono" />
</BaseFormItem>
<BaseFormItem v-if="draft.action === 'set_value'" class="sm:col-span-2" label="固定值(JSON)">
<BaseTextarea v-model="fixedValue" placeholder="例如:&quot;匿名&quot;、null 或 {}" :rows="2" maxlength="4096" aria-label="固定值 JSON" class="font-mono" />
</BaseFormItem>
<div v-if="usesPattern" class="flex flex-wrap gap-x-4 gap-y-2 sm:col-span-2">
<BaseCheckbox v-if="draft.action !== 'remove_field'" v-model="draft.replaceAll" label="替换全部匹配" show-label />
<BaseCheckbox v-model="draft.caseInsensitive" label="忽略大小写" show-label />
<BaseCheckbox v-model="draft.multiLine" label="多行匹配" show-label />
</div>
</BaseForm>
<section class="grid gap-2.5" aria-label="测试预览">
<div class="flex items-center justify-between gap-3">
<h3 class="m-0 text-cp-sm font-emphasis">
替换预览
</h3>
<div class="flex items-center gap-2">
<span v-if="outcome" class="text-cp-xs text-cp-text-quaternary">{{ outcome.status === 'skipped' ? '规则已跳过' : `命中 ${outcome.matches} 处` }}</span>
<BaseButton size="sm" variant="ghost" :loading="loading" aria-label="测试本条规则" @click="test">
<template #icon>
<Play class="size-3.5" />
</template>测试
</BaseButton>
</div>
</div>
<div class="grid min-w-0 gap-3 sm:grid-cols-2">
<BaseFormItem label="测试样本">
<BaseTextarea v-model="sample" size="sm" resize="none" placeholder="粘贴对应作用范围的样本内容" maxlength="262144" aria-label="测试样本" class="font-mono [&_textarea]:block [&_textarea]:h-36" spellcheck="false" />
</BaseFormItem>
<div class="grid min-w-0 content-start gap-2">
<span class="flex min-h-4 items-center text-cp leading-none font-medium text-cp-text-secondary">处理后</span>
<pre class="cp-scrollbar m-0 h-36 overflow-auto rounded-cp bg-cp-fill-quaternary p-3 font-mono text-cp-xs break-all whitespace-pre-wrap" data-preview-output>{{ processed || '点击测试查看结果' }}</pre>
</div>
</div>
<p v-if="localError || error || outcome?.reason" role="alert" class="m-0 text-cp-sm text-cp-error-text">
{{ localError || error || outcome?.reason }}
</p>
</section>
</div>
<template #footer>
<BaseButton size="sm" variant="secondary" @click="open = false">
取消
</BaseButton>
<BaseButton size="sm" variant="primary" :loading="loading" :disabled="disabled" @click="apply">
应用到草稿
</BaseButton>
</template>
</BaseModal>
</template>
@@ -0,0 +1,82 @@
<script setup lang="ts">
import type { PrivacyRule } from '@/api/modules/settings/privacy'
import { BaseIconButton, BaseMenuItem, BasePopover, BaseSwitch, BaseTable, defineTableColumns } from '@codex-proxy/ui'
import { ArrowDown, ArrowUp, Copy, MoreHorizontal, Pencil, Trash2 } from '@lucide/vue'
import { shallowRef } from 'vue'
import { actions, scopes } from './presets'
defineProps<{ rules: PrivacyRule[], disabled: boolean }>()
const emit = defineEmits<{
edit: [rule: PrivacyRule]
toggle: [rule: PrivacyRule, enabled: boolean]
move: [index: number, step: number]
duplicate: [rule: PrivacyRule]
remove: [rule: PrivacyRule]
}>()
const activeMenu = shallowRef('')
const columns = defineTableColumns<PrivacyRule>([
{ key: 'enabled', label: '启用', kind: 'status', size: 'sm', fixedWidth: true },
{ key: 'name', label: '规则 / 字段', kind: 'identity', size: '3xl' },
{ key: 'scope', label: '作用范围', kind: 'meta', size: 'lg' },
{ key: 'action', label: '处理方式', kind: 'meta', size: 'md' },
{ key: 'actions', label: '', kind: 'actions', size: 'sm', fixedWidth: true },
])
</script>
<template>
<BaseTable :columns="columns" :rows="rules" density="compact" empty-text="暂无规则,添加一条或从预设开始">
<template #enabled="{ row }">
<div class="flex items-center justify-center">
<BaseSwitch :disabled="disabled" :model-value="row.enabled" :label="`启用规则:${row.name}`" @update:model-value="emit('toggle', row, $event)" />
</div>
</template>
<template #name="{ row }">
<div class="grid min-w-0 gap-0.5 py-1.5">
<span class="truncate font-emphasis text-cp-text">{{ row.name }}</span>
<span class="truncate font-mono text-cp-xs text-cp-text-quaternary" :title="row.selector">{{ row.selector }}</span>
</div>
</template>
<template #scope="{ row }">
{{ scopes.find(item => item.value === row.scope)?.label }}
</template>
<template #action="{ row }">
{{ actions.find(item => item.value === row.action)?.label }}
</template>
<template #actions="{ row }">
<div class="flex items-center justify-end gap-1">
<BaseIconButton size="sm" :disabled="disabled" variant="ghost" :label="`编辑${row.name}`" @click="emit('edit', row)">
<Pencil class="size-3.5" />
</BaseIconButton>
<BasePopover :model-value="activeMenu === row.id" @update:model-value="activeMenu = $event ? row.id : ''">
<template #trigger>
<BaseIconButton size="sm" :disabled="disabled" :label="`${row.name}的更多操作`" variant="ghost">
<MoreHorizontal class="size-3.5" />
</BaseIconButton>
</template>
<div class="w-40 p-1.5">
<BaseMenuItem :disabled="rules[0]?.id === row.id" @click="emit('move', rules.indexOf(row), -1); activeMenu = ''">
<template #icon>
<ArrowUp class="size-3.5" />
</template>上移
</BaseMenuItem>
<BaseMenuItem :disabled="rules.at(-1)?.id === row.id" @click="emit('move', rules.indexOf(row), 1); activeMenu = ''">
<template #icon>
<ArrowDown class="size-3.5" />
</template>下移
</BaseMenuItem>
<BaseMenuItem @click="emit('duplicate', row); activeMenu = ''">
<template #icon>
<Copy class="size-3.5" />
</template>复制
</BaseMenuItem>
<BaseMenuItem tone="destructive" @click="emit('remove', row); activeMenu = ''">
<template #icon>
<Trash2 class="size-3.5" />
</template>删除规则
</BaseMenuItem>
</div>
</BasePopover>
</div>
</template>
</BaseTable>
</template>
@@ -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<Record<PrivacyScope, { value: string, label: string }[]>> = {
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<PrivacyRule> }[] = [
{ 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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
return `<external_codex_git_state><environment_context><git source="app" trust="untrusted">${text}</git></environment_context></external_codex_git_state>`
}
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 '<environment_context>\n<cwd>/home/alex/projects/demo</cwd>\n<timezone>Asia/Shanghai</timezone>\n</environment_context>'
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 }
}
@@ -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<PrivacyPreviewResult>()
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 }
}
@@ -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<ReturnType<typeof getSettings>>) {
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(),
+2
View File
@@ -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"
/>
<RequestLocationCard v-model="form.requestLocation" v-model:enabled="form.requestLocationEnabled" :disabled="disabled" />
<PrivacyPolicyCard v-model="form.codexPrivacyPolicy" :disabled="disabled" />
<ModelAliasesCard
:mappings="mappings"
:loading="loading"
@@ -128,10 +128,12 @@ function submit() {
<template #footer>
<BasePopover v-model="helpOpen" class="mr-auto self-center" placement="top-start" trigger="hover-click">
<template #trigger>
<button type="button" class="inline-flex h-cp-control cursor-pointer items-center gap-1.5 rounded-cp-sm border-0 bg-transparent p-0 text-cp-sm text-cp-text-tertiary outline-none transition-colors hover:text-cp-primary-text focus-visible:ring-2 focus-visible:ring-cp-control-outline motion-reduce:transition-none" :aria-expanded="helpOpen" :aria-controls="helpOpen ? helpId : undefined">
<Info class="size-3.5" aria-hidden="true" />
<BaseButton variant="ghost" :aria-expanded="helpOpen" :aria-controls="helpOpen ? helpId : undefined">
<template #icon>
<Info class="size-3.5" aria-hidden="true" />
</template>
计价说明
</button>
</BaseButton>
</template>
<div :id="helpId" class="grid w-72 gap-2 p-3 text-cp-xs leading-relaxed text-cp-text-secondary">
<p class="m-0">
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BasePopover } from '@codex-proxy/ui'
import { BaseButton, BasePopover } from '@codex-proxy/ui'
import { Info } from '@lucide/vue'
import { shallowRef, useId } from 'vue'
@@ -10,16 +10,16 @@ const infoId = useId()
<template>
<BasePopover v-model="infoOpen" placement="bottom-start" trigger="hover-click">
<template #trigger>
<button
type="button"
class="inline-flex h-6 cursor-pointer touch-manipulation items-center gap-1.5 rounded-cp-sm border-0 bg-transparent p-0 text-cp-xs font-normal text-cp-text-tertiary outline-none transition-colors hover:text-cp-primary-text focus-visible:ring-2 focus-visible:ring-cp-control-outline motion-reduce:transition-none"
<BaseButton
size="sm"
variant="ghost"
aria-label="价格单位:美元 / 百万 Token,查看定价说明"
:aria-expanded="infoOpen"
:aria-controls="infoOpen ? infoId : undefined"
>
<span aria-hidden="true">USD / 1M Tokens</span>
<Info class="size-3.5" aria-hidden="true" />
</button>
</BaseButton>
</template>
<div :id="infoId" class="grid w-72 gap-2 p-3 text-cp-xs leading-relaxed text-cp-text-secondary">
<p class="m-0 font-emphasis text-cp-text">