Compare commits

..
7 Commits
Author SHA1 Message Date
ZyyandCodex 12c32fcdec chore(release): v3.39.1
Co-authored-by: Codex <noreply@openai.com>
2026-11-03 11:47:32 +08:00
Ke WangandCodex 26de671226 fix(runtime): reload full snapshot on config revision races (#584)
* fix(runtime): reload raced snapshot facts before suspending publication

Co-authored-by: Codex <noreply@openai.com>

* fix(runtime): retry extension preparation when its revision becomes stale

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
2026-11-03 11:38:47 +08:00
Ke WangandCodex a92b95c48c fix(proxies): measure latency for the selected successful address family (#585)
Co-authored-by: Codex <noreply@openai.com>
2026-11-03 11:34:40 +08:00
ZyyandCodex e7c24781fb fix(openai): preserve deployment account routing policy
Co-authored-by: Codex <noreply@openai.com>
2026-11-03 11:33:53 +08:00
ZyyandCodex 3b4b547e9c fix(store): release proxy locks before returning delete conflicts
Co-authored-by: Codex <noreply@openai.com>
2026-11-03 11:23:28 +08:00
ZyyandCodex 2016bda031 fix(openai): preserve client headers and send account installation id
Co-authored-by: Codex <noreply@openai.com>
2026-11-03 11:23:16 +08:00
ZyyandCodex 790f3147e0 fix(openai): preserve verified turn state on full requests
Track bounded ownership proofs for upstream turn-state tokens, including failed responses, so same-turn full requests can echo them without trusting unrelated account state.

Co-authored-by: Codex <noreply@openai.com>
2026-11-03 10:19:53 +08:00
30 changed files with 1089 additions and 200 deletions
@@ -618,8 +618,6 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
| "chatgpt-project-id"
| "openai-organization"
| "openai-project"
| "x-openai-account-routing-override"
| "x-openai-fedramp"
// 上游指纹必须由运行时画像统一生成,客户端 originator/User-Agent/version
// 不能作为不透明头透传覆盖,避免不同下游客户端暴露不一致的设备指纹
| "originator"
@@ -431,6 +431,9 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
values("x-codex-installation-id"),
vec![b"client-installation".to_vec()]
);
for name in ["x-openai-account-routing-override", "x-openai-fedramp"] {
assert_eq!(values(name), vec![b"first".to_vec(), b"second".to_vec()]);
}
for preserved in [
"accept",
"content-type",
@@ -450,8 +453,6 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
"cookie",
"chatgpt-account-id",
"chatgpt-project-id",
"x-openai-account-routing-override",
"x-openai-fedramp",
// 上游指纹由运行时画像统一生成,客户端不得覆盖
"user-agent",
"originator",
@@ -23,7 +23,7 @@ use super::{
RuntimeAccountDirectory, UpstreamModelId,
};
const MAXIMUM_CATALOG_STABILITY_ATTEMPTS: usize = 4;
const MAXIMUM_SNAPSHOT_STABILITY_ATTEMPTS: usize = 4;
type ModelCatalogAccounts = BTreeMap<ProviderKind, BTreeMap<String, BTreeSet<ProviderAccountId>>>;
@@ -328,14 +328,17 @@ impl RuntimeSnapshotCompiler {
ConfigRevision,
crate::routing::extensions::ExtensionSetReference,
)> = None;
for _ in 0..MAXIMUM_CATALOG_STABILITY_ATTEMPTS {
let mut instability = RuntimeSnapshotCompileError::CatalogChanged;
for _ in 0..MAXIMUM_SNAPSHOT_STABILITY_ATTEMPTS {
let facts = self
.store
.load_snapshot_facts()
.await
.map_err(|_| RuntimeSnapshotCompileError::StoreUnavailable)?;
if facts.config_revision != facts.observed_current_revision {
return Err(RuntimeSnapshotCompileError::RevisionChanged);
// 并发提交使本轮事实过期时重读完整快照,不能混用不同版本的权限
instability = RuntimeSnapshotCompileError::RevisionChanged;
continue;
}
let revision = facts.config_revision;
let extensions = match &self.extensions {
@@ -347,7 +350,7 @@ impl RuntimeSnapshotCompiler {
*prepared_revision != revision || !prepared.is_ready()
});
if must_prepare {
let prepared = preparer
let prepared = match preparer
.prepare_with_request_body_limit(
revision,
usize::try_from(
@@ -356,7 +359,21 @@ impl RuntimeSnapshotCompiler {
.unwrap_or(usize::MAX),
)
.await
.map_err(|_| RuntimeSnapshotCompileError::ExtensionsUnavailable)?;
{
Ok(prepared) => prepared,
Err(_) => {
// 扩展读取也可能遇到并发提交;只有版本已变才重读事实
let observed =
self.store.current_config_revision().await.map_err(|_| {
RuntimeSnapshotCompileError::StoreUnavailable
})?;
if observed == revision {
return Err(RuntimeSnapshotCompileError::ExtensionsUnavailable);
}
instability = RuntimeSnapshotCompileError::RevisionChanged;
continue;
}
};
prepared_extensions = Some((revision, prepared));
}
prepared_extensions
@@ -390,7 +407,8 @@ impl RuntimeSnapshotCompiler {
.map_err(|_| RuntimeSnapshotCompileError::StoreUnavailable)?
!= revision
{
return Err(RuntimeSnapshotCompileError::RevisionChanged);
instability = RuntimeSnapshotCompileError::RevisionChanged;
continue;
}
let snapshot = snapshot
.with_provider_catalog_generations(if cached.is_some() && !refresh_catalog {
@@ -401,8 +419,9 @@ impl RuntimeSnapshotCompiler {
.with_extensions(extensions);
return Ok(snapshot);
}
instability = RuntimeSnapshotCompileError::CatalogChanged;
}
Err(RuntimeSnapshotCompileError::CatalogChanged)
Err(instability)
}
}
@@ -730,12 +730,116 @@ impl ProviderCatalogPort for PublishingCatalog {
#[test]
fn compiler_should_reject_revision_changed_during_consistent_read() {
let facts = facts(1, 2);
let compiler = compiler(Arc::new(TestSnapshotStore::new(Ok(facts))));
let store = Arc::new(SequencedSnapshotStore {
facts: vec![facts(1, 2)],
loads: AtomicUsize::new(0),
current_revision: revision(2),
});
let compiler = compiler(store.clone());
let error = block_on(compiler.compile()).expect_err("revision drift must fail closed");
assert_eq!(error, RuntimeSnapshotCompileError::RevisionChanged);
assert_eq!(store.loads.load(Ordering::SeqCst), 4);
}
#[test]
fn compiler_reloads_complete_facts_after_revision_changes_during_read() {
let store = Arc::new(SequencedSnapshotStore {
facts: vec![facts(1, 2), facts(2, 2)],
loads: AtomicUsize::new(0),
current_revision: revision(2),
});
let snapshot = block_on(compiler(store.clone()).compile()).expect("latest consistent facts");
assert_eq!(snapshot.revision(), revision(2));
assert_eq!(store.loads.load(Ordering::SeqCst), 2);
}
#[test]
fn compiler_reloads_revision_changed_after_extension_preparation() {
let drops = Arc::new(AtomicUsize::new(0));
let extensions = Arc::new(CountingExtensionPreparation {
prepares: AtomicUsize::new(0),
drops: Arc::clone(&drops),
});
let compiler = RuntimeSnapshotCompiler::new(
Arc::new(SequencedSnapshotStore {
facts: vec![facts(1, 1), facts(2, 2)],
loads: AtomicUsize::new(0),
current_revision: revision(2),
}),
Arc::new(TestCatalog::Empty),
)
.with_extensions(extensions.clone());
let snapshot = block_on(compiler.compile()).expect("latest prepared snapshot");
assert_eq!(snapshot.revision(), revision(2));
assert_eq!(snapshot.extensions().unwrap().id().as_str(), "prepared-2");
assert_eq!(extensions.prepares.load(Ordering::SeqCst), 2);
assert_eq!(drops.load(Ordering::SeqCst), 1);
drop(snapshot);
assert_eq!(drops.load(Ordering::SeqCst), 2);
}
#[test]
fn compiler_rechecks_revision_when_extension_preparation_rejects_stale_facts() {
struct RacingPreparation {
store: Arc<TestSnapshotStore>,
changes: usize,
calls: AtomicUsize,
}
impl ExtensionPreparationPort for RacingPreparation {
fn prepare(
&self,
_: ConfigRevision,
) -> BoxFuture<'_, Result<ExtensionSetReference, ExtensionPreparationError>> {
Box::pin(async move {
let call = self.calls.fetch_add(1, Ordering::SeqCst);
if self.changes == 0 || call < self.changes {
if self.changes != 0 {
let next = u64::try_from(call).unwrap() + 2;
*self.store.facts.lock().unwrap() = Ok(facts(next, next));
*self.store.current_revision.lock().unwrap() = Ok(revision(next));
}
return Err(ExtensionPreparationError);
}
Ok(ExtensionSetReference::new(
ExtensionSetId::new("ready".to_owned()).unwrap(),
Arc::new(AliasLease(vec![])),
))
})
}
}
for (changes, expected_calls) in [(0, 1), (1, 2), (4, 4)] {
let store = Arc::new(TestSnapshotStore::new(Ok(facts(1, 1))));
let extensions = Arc::new(RacingPreparation {
store: store.clone(),
changes,
calls: AtomicUsize::new(0),
});
let compiler = RuntimeSnapshotCompiler::new(store, Arc::new(TestCatalog::Empty))
.with_extensions(extensions.clone());
let result = block_on(compiler.compile());
match changes {
0 => assert_eq!(
result.unwrap_err(),
RuntimeSnapshotCompileError::ExtensionsUnavailable
),
1 => assert_eq!(result.unwrap().revision(), revision(2)),
_ => assert_eq!(
result.unwrap_err(),
RuntimeSnapshotCompileError::RevisionChanged
),
}
assert_eq!(extensions.calls.load(Ordering::SeqCst), expected_calls);
}
}
#[test]
@@ -326,6 +326,39 @@ fn publisher_should_suspend_but_still_notify_after_committed_refresh_failure() {
);
}
#[test]
fn committed_refresh_reloads_raced_facts_without_losing_revoked_scope() {
block_on(async {
let store = Arc::new(TestSnapshotStore::new(Ok(scoped_facts(10, true))));
let initial = compiler(store.clone())
.compile()
.await
.expect("initial scope");
let handle = RuntimeSnapshotHandle::new(initial);
let frozen = handle.acquire().expect("initial request");
assert!(removed_account_allowed(&frozen));
let publisher = RuntimeSnapshotPublisher::new(
Arc::new(compiler(store.clone())),
handle.clone(),
Arc::new(TestSnapshotSubscriptions::default()),
);
let (release, gate) = oneshot::channel();
*store.next_load.lock().expect("load gate lock") = Some(gate);
let mut committed = publisher.publish_committed(revision(11));
assert!(futures::poll!(committed.as_mut()).is_pending());
*store.facts.lock().expect("facts lock") = Ok(scoped_facts(11, false));
*store.current_revision.lock().expect("revision lock") = Ok(revision(11));
release.send(Ok(facts(10, 11))).expect("release raced read");
committed.await;
let current = handle.acquire().expect("latest snapshot remains available");
assert_eq!(current.revision(), revision(11));
assert!(!removed_account_allowed(&current));
assert!(removed_account_allowed(&frozen));
});
}
#[test]
fn publisher_should_contribute_reconciliation_and_subscription_workers() {
let store = Arc::new(TestSnapshotStore::new(Ok(facts(1, 1))));
+22 -3
View File
@@ -125,6 +125,17 @@ impl HttpProxyProbe {
}
impl HttpProxyProbe {
async fn timed_exit_ip_at(
&self,
proxy: Option<&OutboundProxy>,
endpoint: &str,
) -> (Result<IpAddr, &'static str>, u64) {
let started = Instant::now();
let result = self.exit_ip_at(proxy, endpoint).await;
let latency_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
(result, latency_ms)
}
async fn test_connection(&self, proxy: Option<&OutboundProxy>) -> ProxyTestResult {
let started = Instant::now();
let timeout_limit = Duration::from_secs(15);
@@ -169,15 +180,15 @@ impl HttpProxyProbe {
} => {
let probe_dual = async {
tokio::join!(
self.exit_ip_at(proxy, ipv4_endpoint),
self.exit_ip_at(proxy, ipv6_endpoint),
self.timed_exit_ip_at(proxy, ipv4_endpoint),
self.timed_exit_ip_at(proxy, ipv6_endpoint),
)
};
let result = tokio::time::timeout(timeout_limit, probe_dual).await;
let latency_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
match result {
Ok((res_v4, res_v6)) => {
Ok(((res_v4, latency_v4), (res_v6, latency_v6))) => {
let exit_ipv4: Option<Ipv4Addr> = match res_v4 {
Ok(IpAddr::V4(v4)) => Some(v4),
_ => None,
@@ -186,6 +197,14 @@ impl HttpProxyProbe {
Ok(IpAddr::V6(v6)) => Some(v6),
_ => None,
};
// 延迟对应首选的可用出口,另一地址族失败等待不代表该出口延迟
let latency_ms = if exit_ipv4.is_some() {
latency_v4
} else if exit_ipv6.is_some() {
latency_v6
} else {
latency_ms
};
if exit_ipv4.is_some() && exit_ipv6.is_some() {
ProxyTestResult {
@@ -155,6 +155,39 @@ async fn dual_stack_proxy_probe_reports_single_stack_when_only_one_succeeds() {
assert!(result.message.contains("仅 IPv4"));
}
#[tokio::test]
async fn dual_stack_latency_measures_the_selected_exit_without_the_other_family_wait() {
use std::time::{Duration, Instant};
for working_family in ["v4", "v6"] {
let server = MockServer::start().await;
for (family, ip) in [("v4", "203.0.113.8"), ("v6", "2001:db8::8")] {
let response = if family == working_family {
ResponseTemplate::new(200).set_body_json(json!({"ip": ip}))
} else {
ResponseTemplate::new(503).set_delay(Duration::from_millis(500))
};
Mock::given(path(format!("/{family}")))
.respond_with(response)
.expect(1)
.mount(&server)
.await;
}
let started = Instant::now();
let result = HttpProxyProbe::new_dual(
format!("{}/v4", server.uri()),
format!("{}/v6", server.uri()),
)
.test(None, false)
.await;
assert!(result.success);
assert_eq!(result.exit_ipv4.is_some(), working_family == "v4");
assert_eq!(result.exit_ipv6.is_some(), working_family == "v6");
assert!(started.elapsed() >= Duration::from_millis(500));
assert!(u128::from(result.latency_ms) + 400 < started.elapsed().as_millis());
}
}
fn location_response(ip: &str, timezone: &str) -> serde_json::Value {
json!({"success": true, "ip": ip, "country_code": "JP", "region": "Tokyo", "city": "Tokyo", "timezone": {"id": timezone}})
}
@@ -857,7 +857,12 @@ impl ProxyStore for PgProxyRepository {
.await
.map_err(|source| store_error(unavailable(source)))?;
if locked.is_none() {
return Err(store_error(conflict(id)));
// 返回版本冲突前释放事务锁,避免误挡紧接着的删除或编辑
let error = match transaction.rollback().await {
Ok(()) => conflict(id),
Err(cleanup) => conflict(id).with_cleanup(cleanup),
};
return Err(store_error(error));
}
let account_ids: Vec<String> = sqlx::query_scalar(
"update provider_accounts set outbound_proxy_id = null, outbound_proxy_url = null, provider_quota_json = null, quota_observed_at = null,
@@ -735,8 +735,8 @@ async fn rejected_import_reservations_release_proxy_lock_before_returning() {
}
#[tokio::test]
async fn stale_proxy_test_releases_lock_before_next_update() {
let Some(database) = TestDatabase::create("stale_proxy_test_lock").await else {
async fn stale_proxy_mutations_release_locks_before_next_update() {
let Some(database) = TestDatabase::create("stale_proxy_mutation_lock").await else {
return;
};
let store = PgProxyRepository::new(database.pool.clone());
@@ -762,52 +762,63 @@ async fn stale_proxy_test_releases_lock_before_next_update() {
.unwrap()
.record;
// 暂停连接归还时的后台清理,确保锁由返回错误前的显式回滚释放
let release_gate = std::sync::Arc::new(tokio::sync::Semaphore::new(0));
let rejected_pool = database
.pool
.options()
.clone()
.max_connections(1)
.after_release({
let release_gate = release_gate.clone();
move |_, _| {
for mutation in ["record_test", "update", "delete"] {
// 暂停连接归还时的后台清理,确保锁由返回错误前的显式回滚释放
let release_gate = std::sync::Arc::new(tokio::sync::Semaphore::new(0));
let rejected_pool = database
.pool
.options()
.clone()
.max_connections(1)
.after_release({
let release_gate = release_gate.clone();
Box::pin(async move {
let _permit = release_gate.acquire().await.unwrap();
Ok(true)
})
}
})
.connect_with((*database.pool.connect_options()).clone())
.await
.unwrap();
let stale_writer = PgProxyRepository::new(rejected_pool.clone());
let error = stale_writer
.record_test(&saved.id, stale_revision, success(), &context)
.await
.unwrap_err();
let update = store
.update(
UpdateProxy {
max_websocket_connections: None,
enabled: None,
id: saved.id.clone(),
revision: saved.revision,
name: saved.name.clone(),
proxy: None,
auto_location: None,
location: None,
test: None,
},
&context,
)
.await;
release_gate.add_permits(1);
rejected_pool.close().await;
move |_, _| {
let release_gate = release_gate.clone();
Box::pin(async move {
let _permit = release_gate.acquire().await.unwrap();
Ok(true)
})
}
})
.connect_with((*database.pool.connect_options()).clone())
.await
.unwrap();
let stale_writer = PgProxyRepository::new(rejected_pool.clone());
let command = |revision| UpdateProxy {
max_websocket_connections: None,
enabled: None,
id: saved.id.clone(),
revision,
name: saved.name.clone(),
proxy: None,
auto_location: None,
location: None,
test: None,
};
let error = match mutation {
"record_test" => stale_writer
.record_test(&saved.id, stale_revision, success(), &context)
.await
.unwrap_err(),
"update" => stale_writer
.update(command(stale_revision), &context)
.await
.unwrap_err(),
"delete" => stale_writer
.delete(&saved.id, stale_revision, &context)
.await
.unwrap_err(),
_ => unreachable!(),
};
let update = store.update(command(saved.revision), &context).await;
release_gate.add_permits(1);
rejected_pool.close().await;
assert_eq!(error.kind(), AdminStoreErrorKind::Conflict);
saved = update
.unwrap_or_else(|error| panic!("过期 {mutation} 不应阻挡后续编辑: {error:?}"))
.record;
}
database.close().await;
assert_eq!(error.kind(), AdminStoreErrorKind::Conflict);
assert!(update.is_ok(), "过期检测结果不应阻挡后续编辑: {update:?}");
}
#[tokio::test]
@@ -397,6 +397,7 @@ pub(super) struct ColdResponse {
pub(super) session_transport_key_hash: Option<String>,
pub(super) session_transport_recovery: CodexSessionTransportRecovery,
pub(super) guardian_routing: guardian::GuardianRouting,
pub(super) turn_state_scope: Option<turn_state::TurnStateScope>,
pub(super) websocket_retry_count: u32,
pub(super) stream_max_retries: u32,
pub(super) session_capture: Option<OpenAiSessionCapture>,
@@ -784,6 +785,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
session_transport_key_hash,
session_transport_recovery,
guardian_routing,
turn_state_scope,
websocket_retry_count,
stream_max_retries,
mut session_capture,
@@ -873,6 +875,17 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
},
);
}
if let Some(scope) = &turn_state_scope
&& let Some(response) = failure.error.client_visible_upstream_response()
{
for header in response.headers() {
if header.name().eq_ignore_ascii_case("x-codex-turn-state")
&& let Ok(token) = std::str::from_utf8(header.value().as_ref())
{
scope.remember(token);
}
}
}
if let Some(observation) = failure.observation.take() {
yield ProviderEvent::observation(observation);
}
@@ -897,6 +910,11 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
Err(failure.error)?;
return;
}
if let Some(scope) = &turn_state_scope
&& let Some(token) = response.turn_state.as_deref()
{
scope.remember(token);
}
if let Some(capture) = session_capture.as_mut() {
capture.continuation_scope = Some(if capture.response_store {
OpenAiContinuationScope::Persisted
@@ -1040,6 +1058,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
response_metadata_updates.as_ref(),
&mut session_capture,
&mut observation_state,
turn_state_scope.as_ref(),
)
.await;
let observation_event = if rate_limits_changed || metadata_merge.is_some()
@@ -1117,6 +1136,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
response_metadata_updates.as_ref(),
&mut session_capture,
&mut observation_state,
turn_state_scope.as_ref(),
)
.await;
let metadata_changed = metadata_merge.unwrap_or(false);
@@ -1317,6 +1337,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
response_metadata_updates.as_ref(),
&mut session_capture,
&mut observation_state,
turn_state_scope.as_ref(),
)
.await
.unwrap_or(false);
@@ -1384,8 +1405,12 @@ async fn merge_response_metadata_updates(
updates: Option<&CodexResponseMetadataUpdates>,
session_capture: &mut Option<OpenAiSessionCapture>,
observation_state: &mut OpenAiResponseObservationState,
turn_state_scope: Option<&turn_state::TurnStateScope>,
) -> Option<bool> {
let turn_state = updates?.lock().await.turn_state.take()?;
if let Some(scope) = turn_state_scope {
scope.remember(&turn_state);
}
if let Some(capture) = session_capture.as_mut() {
capture.turn_state = Some(turn_state.clone());
}
@@ -107,6 +107,7 @@ mod failure;
mod guardian;
mod live;
mod observation;
mod turn_state;
mod upstream_adapter;
mod workers;
pub(crate) use live::{CodexLiveGateway, CodexLiveRegistry};
@@ -156,6 +157,7 @@ pub struct CodexProvider {
session_identity: Option<CodexSessionIdentity>,
session_transport_recovery: CodexSessionTransportRecovery,
guardian_routing: guardian::GuardianRouting,
turn_state_ownership: turn_state::TurnStateOwnership,
stream_max_retries: u32,
live_registry: Arc<CodexLiveRegistry>,
live_gateway: Option<Arc<CodexLiveGateway>>,
@@ -361,6 +363,7 @@ impl CodexProvider {
session_identity: None,
session_transport_recovery: CodexSessionTransportRecovery::default(),
guardian_routing: guardian::GuardianRouting::default(),
turn_state_ownership: turn_state::TurnStateOwnership::default(),
stream_max_retries,
live_registry: Arc::new(CodexLiveRegistry::default()),
live_gateway: None,
@@ -963,10 +966,25 @@ impl CodexProvider {
)
.map(|affinity| (response_id.to_owned(), affinity))
});
let turn_state_scope = self
.turn_state_ownership
.scope(&context, &lease, &upstream_request);
// 全量请求的 token 证明不能用于放行 response ID 或其他账号状态
let verified_turn_state = upstream_request
.turn_state
.as_ref()
.filter(|token| {
matches!(account_scope, RequestAccountScope::Unknown)
&& turn_state_scope
.as_ref()
.is_some_and(|scope| scope.owns(token))
})
.cloned();
scope_request_to_account(
&mut upstream_request,
lease.installation_id(),
account_scope,
verified_turn_state.as_deref(),
);
// 父响应的可信关联只恢复此字段,不把首次 Guardian 请求的其他账号状态视为可信
if let Some((response_id, _)) = &guardian_parent
@@ -989,6 +1007,7 @@ impl CodexProvider {
"ownerCrossAccount": state_owner_cross_account,
"accountSwitch": lease.account_switch(),
"accountStateReusable": account_scope.can_reuse_account_state(),
"verifiedTurnStateEcho": verified_turn_state.is_some(),
"before": before,
"after": crate::transport::diagnostics::evidence::request_state(&upstream_request),
}));
@@ -1162,6 +1181,7 @@ impl CodexProvider {
session_transport_key_hash,
session_transport_recovery: self.session_transport_recovery.clone(),
guardian_routing: self.guardian_routing.clone(),
turn_state_scope,
websocket_retry_count,
stream_max_retries: self.stream_max_retries,
session_capture,
@@ -0,0 +1,124 @@
//! 只为上游已返回的 turn state 保存归属证明,不恢复正文或参与账号选择
use std::{
collections::HashMap,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use gateway_core::engine::AttemptContext;
use sha2::{Digest, Sha256};
use crate::{
credential::{CodexCredentialLease, CodexRuntimeAuthentication},
transport::protocol::responses::CodexResponsesRequest,
};
// 仅保存 32 字节摘要与过期时间,4096 项的条目数据约 192 KiB,不随 token 长度增长
const MAX_PROOFS: usize = 4096;
#[derive(Clone, Default)]
pub(super) struct TurnStateOwnership {
proofs: Arc<Mutex<HashMap<[u8; 32], Instant>>>,
}
pub(super) struct TurnStateScope {
ownership: TurnStateOwnership,
identity: Sha256,
retention: Duration,
}
impl TurnStateOwnership {
pub(super) fn scope(
&self,
context: &AttemptContext,
lease: &CodexCredentialLease,
request: &CodexResponsesRequest,
) -> Option<TurnStateScope> {
let turn_id = request
.client_turn_id
.as_deref()
.filter(|id| !id.is_empty())?;
let mut identity = Sha256::new();
for part in [
context.client_api_key_ref().as_str(),
lease.account_id().as_str(),
request.client_session_id.as_deref().unwrap_or_default(),
request.client_thread_id.as_deref().unwrap_or_default(),
turn_id,
] {
identity.update(part.len().to_le_bytes());
identity.update(part.as_bytes());
}
// OAuth 刷新不改变账号归属;API Key 替换则不能继承原认证的路由状态
if matches!(
lease.authentication(),
CodexRuntimeAuthentication::ApiKey(_)
) {
identity.update([1]);
identity.update(lease.account().revision().get().to_le_bytes());
} else {
identity.update([0]);
}
Some(TurnStateScope {
ownership: self.clone(),
identity,
retention: context
.account_selection_policy()
.openai_session_affinity_ttl(),
})
}
}
impl TurnStateScope {
pub(super) fn owns(&self, token: &str) -> bool {
let key = self.key(token);
let mut proofs = self
.ownership
.proofs
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(expires_at) = proofs.get_mut(&key) else {
return false;
};
let now = Instant::now();
if *expires_at <= now {
proofs.remove(&key);
return false;
}
*expires_at = now + self.retention;
true
}
pub(super) fn remember(&self, token: &str) {
if token.is_empty() {
return;
}
let key = self.key(token);
let now = Instant::now();
let mut proofs = self
.ownership
.proofs
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if proofs.len() >= MAX_PROOFS && !proofs.contains_key(&key) {
proofs.retain(|_, expires_at| *expires_at > now);
if proofs.len() >= MAX_PROOFS
&& let Some(oldest) = proofs
.iter()
.min_by_key(|(_, expires_at)| **expires_at)
.map(|(key, _)| *key)
{
proofs.remove(&oldest);
}
}
proofs.insert(key, now + self.retention);
}
fn key(&self, token: &str) -> [u8; 32] {
let mut hash = self.identity.clone();
hash.update(token.len().to_le_bytes());
hash.update(token.as_bytes());
hash.finalize().into()
}
}
@@ -23,16 +23,6 @@ const ID_FIELDS: &[&str] = &[
"parentThreadId",
];
const METADATA_FIELDS: &[&str] = &["turnMetadata", "turn_metadata", "x-codex-turn-metadata"];
const TRACE_FIELDS: &[&str] = &[
"traceparent",
"tracestate",
"sentry-trace",
"baggage",
"ws_request_header_traceparent",
"ws_request_header_tracestate",
"ws_request_header_sentry-trace",
"ws_request_header_baggage",
];
// Live 的 x-session-id 与 voice-session-id 是业务会话引用,必须原样传递
const ID_HEADERS: &[&str] = &[
"session-id",
@@ -102,25 +92,14 @@ fn map_fields(object: &mut Map<String, Value>, installation_id: &str, fields: &[
changed
}
fn scope_object(object: &mut Map<String, Value>, installation_id: Option<&str>) -> usize {
let mut changed = 0;
for key in TRACE_FIELDS {
changed += usize::from(object.remove(*key).is_some());
}
if let Some(installation_id) = installation_id {
changed += map_fields(object, installation_id, ID_FIELDS);
}
changed
}
fn scope_metadata(value: &mut Value, installation_id: Option<&str>) {
fn scope_metadata(value: &mut Value, installation_id: &str) {
match value {
Value::Object(object) => {
scope_object(object, installation_id);
map_fields(object, installation_id, ID_FIELDS);
}
Value::String(raw) => {
if let Ok(Value::Object(mut object)) = serde_json::from_str(raw)
&& scope_object(&mut object, installation_id) > 0
&& map_fields(&mut object, installation_id, ID_FIELDS) > 0
&& let Some(encoded) = serialize_ascii_turn_metadata(&Value::Object(object))
{
*raw = encoded;
@@ -131,29 +110,27 @@ fn scope_metadata(value: &mut Value, installation_id: Option<&str>) {
}
pub(super) fn scope_headers(headers: &mut HeaderMap, installation_id: Option<&str>) {
for name in TRACE_FIELDS {
headers.remove(*name);
}
let Some(installation_id) = installation_id else {
return;
};
for name in ID_HEADERS {
if let Some(installation_id) = installation_id {
let values: Vec<_> = headers
.get_all(*name)
.iter()
.map(|value| {
value
.to_str()
.ok()
.filter(|value| !value.is_empty())
.and_then(|value| {
HeaderValue::from_str(&scoped_id(installation_id, value)).ok()
})
.unwrap_or_else(|| value.clone())
})
.collect();
headers.remove(*name);
for value in values {
headers.append(*name, value);
}
let values: Vec<_> = headers
.get_all(*name)
.iter()
.map(|value| {
value
.to_str()
.ok()
.filter(|value| !value.is_empty())
.and_then(|value| {
HeaderValue::from_str(&scoped_id(installation_id, value)).ok()
})
.unwrap_or_else(|| value.clone())
})
.collect();
headers.remove(*name);
for value in values {
headers.append(*name, value);
}
}
let values: Vec<_> = headers
@@ -183,18 +160,19 @@ pub(super) fn scope_request(
installation_id: Option<&str>,
) {
scope_headers(headers, installation_id);
let Some(installation_id) = installation_id else {
return;
};
if let Some(object) = body.as_object_mut() {
scope_object(object, installation_id);
if let Some(installation_id) = installation_id {
map_fields(object, installation_id, &["prompt_cache_key"]);
}
map_fields(object, installation_id, ID_FIELDS);
map_fields(object, installation_id, &["prompt_cache_key"]);
for key in METADATA_FIELDS {
if let Some(value) = object.get_mut(*key) {
scope_metadata(value, installation_id);
}
}
if let Some(Value::Object(metadata)) = object.get_mut("client_metadata") {
scope_object(metadata, installation_id);
map_fields(metadata, installation_id, ID_FIELDS);
for key in METADATA_FIELDS {
if let Some(value) = metadata.get_mut(*key) {
scope_metadata(value, installation_id);
@@ -202,9 +180,6 @@ pub(super) fn scope_request(
}
}
}
let Some(installation_id) = installation_id else {
return;
};
// 只处理官方自动 Git 上下文的关联字段,不扫描普通用户输入和工具输出
let Ok(targets) = locate_contexts(body, true, || Ok(())) else {
return;
@@ -28,16 +28,15 @@ pub(super) fn is_managed_identity_header(name: &str) -> bool {
| "chatgpt-project-id"
| "openai-organization"
| "openai-project"
// 工作区路由和合规属性属于认证账号,不能继承下游账号的值
| "x-openai-account-routing-override"
| "x-openai-fedramp"
// 安装身份由当前账号写入 client_metadata,不继承下游安装头
// 安装头与 client_metadata 共用当前账号的安装身份
| "x-codex-installation-id"
// 画像和设备证明须属于选定账号,不能由下游覆盖
| "originator"
| "user-agent"
| "version"
// 地域和账号路由统一按部署配置生成
| "x-openai-internal-codex-residency"
| "x-openai-account-routing-override"
| "x-oai-attestation"
| "x-oai-is"
| "x-oai-is-update"
@@ -127,6 +126,11 @@ impl CodexBackendClient {
};
let mut headers = build_codex_model_headers(profile, context.authorization, account_id)?;
insert_optional_header(&mut headers, "cookie", cookie_header)?;
insert_optional_header(
&mut headers,
"x-codex-installation-id",
context.installation_id,
)?;
Ok(headers)
}
@@ -469,19 +469,17 @@ pub(crate) fn scope_request_to_account(
request: &mut CodexResponsesRequest,
installation_id: &str,
account_scope: RequestAccountScope,
verified_turn_state: Option<&str>,
) {
let reset_account_state = !account_scope.can_reuse_account_state();
let client_metadata_turn_state = metadata_string(request, "x-codex-turn-state");
let preserve_turn_state = !reset_account_state
&& (request.turn_state.is_some() || client_metadata_turn_state.is_some());
let turn_state = preserve_turn_state
.then(|| request.turn_state.clone())
.flatten();
let client_metadata_turn_state = if preserve_turn_state {
client_metadata_turn_state
let turn_state = if reset_account_state {
verified_turn_state.map(ToOwned::to_owned)
} else {
None
request.turn_state.clone()
};
let client_metadata_turn_state = client_metadata_turn_state
.filter(|token| !reset_account_state || Some(token.as_str()) == verified_turn_state);
let turn_metadata = request
.turn_metadata
.as_deref()
@@ -45,6 +45,7 @@ const DOWNSTREAM_CLIENT_HEADERS: &[(&str, &str)] = &[
];
const BUSINESS_EXTENSION_HEADERS: &[(&str, &str)] = &[
("x-openai-fedramp", "true"),
("x-grok-turn-idx", "7"),
("X-Grok-Model-Override", "synthetic-model"),
("x-grok-session-id", "synthetic-grok-session"),
@@ -77,6 +78,10 @@ fn request_with_opaque_headers(use_websocket: bool) -> CodexResponsesRequest {
"x-openai-internal-codex-residency",
STANDARD.encode(b"future-region")
],
[
"X-OpenAI-Account-Routing-Override",
STANDARD.encode(b"client-route")
],
["x-codex-turn-state", STANDARD.encode(b"turn-ascii")],
["x-codex-turn-state", STANDARD.encode(b"turn-\x80")],
["bad header name", STANDARD.encode(b"ignored")],
@@ -202,7 +207,10 @@ async fn backend_http_should_preserve_business_headers_without_downstream_transp
});
// 不经 API 解码,直接构造协议上下文,验证 Provider 自身的过滤边界
let request = request_with_opaque_headers(false);
let profile = test_wire_profile();
let mut configured_profile = test_wire_profile().snapshot();
configured_profile.account_routing_override = Some("configured-route".into());
let profile =
provider_openai::transport::profile::CodexWireProfileState::new(configured_profile);
let profile_snapshot = profile.snapshot();
let expected_user_agent = profile_snapshot.user_agent();
let expected_core_version = profile_snapshot.codex_version;
@@ -277,7 +285,18 @@ async fn backend_http_should_preserve_business_headers_without_downstream_transp
] {
assert_eq!(raw_header_values(&raw, name), vec![value.to_vec()]);
}
assert!(raw_header_values(&raw, "traceparent").is_empty());
assert_eq!(
raw_header_values(&raw, "traceparent"),
vec![b"synthetic-trace".to_vec()]
);
assert_eq!(
raw_header_values(&raw, "x-openai-account-routing-override"),
vec![b"configured-route".to_vec()]
);
assert_eq!(
raw_header_values(&raw, "x-codex-installation-id"),
vec![b"lease-installation".to_vec()]
);
let session = raw_header_values(&raw, "session-id");
assert!(uuid::Uuid::parse_str(std::str::from_utf8(&session[0]).unwrap()).is_ok());
// 指纹头由运行时画像生成,不透传客户端值
@@ -295,7 +314,6 @@ async fn backend_http_should_preserve_business_headers_without_downstream_transp
);
for dropped in [
"openai-beta",
"x-codex-installation-id",
"x-openai-actor-authorization",
"x-oai-attestation",
"x-oai-is",
@@ -358,13 +376,15 @@ async fn backend_websocket_should_preserve_business_headers_without_downstream_t
.expect("send opaque terminal event");
});
let request = request_with_opaque_headers(true);
let mut profile = test_wire_profile().snapshot();
profile.account_routing_override = Some("configured-route".into());
let client = CodexBackendClient::new(
reqwest::Client::builder()
.no_proxy()
.build()
.expect("HTTP client"),
format!("http://{address}"),
test_wire_profile(),
provider_openai::transport::profile::CodexWireProfileState::new(profile),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
@@ -430,7 +450,6 @@ async fn backend_websocket_should_preserve_business_headers_without_downstream_t
assert_eq!(values("version"), vec![b"1.2.3".to_vec()]);
assert!(values("x-openai-internal-codex-residency").is_empty());
for dropped in [
"x-codex-installation-id",
"x-openai-actor-authorization",
"x-oai-attestation",
"x-oai-is",
@@ -462,7 +481,15 @@ async fn backend_websocket_should_preserve_business_headers_without_downstream_t
] {
assert_eq!(values(name), vec![value.to_vec()], "missing {name}");
}
assert!(values("traceparent").is_empty());
assert_eq!(values("traceparent"), vec![b"synthetic-trace".to_vec()]);
assert_eq!(
values("x-openai-account-routing-override"),
vec![b"configured-route".to_vec()]
);
assert_eq!(
values("x-codex-installation-id"),
vec![b"lease-installation".to_vec()]
);
let session = values("session-id");
assert!(uuid::Uuid::parse_str(std::str::from_utf8(&session[0]).unwrap()).is_ok());
assert_eq!(values("x-codex-turn-state"), vec![b"turn-ascii".to_vec()]);
@@ -71,12 +71,15 @@ fn assert_isolated_request(
body: &Value,
owner: Option<&str>,
) {
for name in ["x-openai-account-routing-override", "x-openai-fedramp"] {
assert!(
!headers.contains_key(name),
"leaked {name} with owner {owner:?}"
);
}
assert!(!headers.contains_key("x-openai-account-routing-override"));
assert_eq!(
headers
.get_all("x-openai-fedramp")
.iter()
.map(|value| value.to_str().unwrap())
.collect::<Vec<_>>(),
["true", "false"]
);
assert_eq!(headers["x-codex-guardian"], "true");
assert_eq!(
headers
@@ -112,7 +115,7 @@ fn assert_isolated_request(
}
#[tokio::test]
async fn http_isolates_account_headers_and_only_scopes_guardian_parent_reference() {
async fn http_applies_deployment_routing_and_only_scopes_guardian_parent_reference() {
for owner in [Some(SELECTED_ACCOUNT), Some("acct_scope_old"), None] {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, SELECTED_ACCOUNT).await;
@@ -155,7 +158,7 @@ async fn http_isolates_account_headers_and_only_scopes_guardian_parent_reference
}
#[tokio::test]
async fn websocket_isolates_account_headers_and_only_scopes_guardian_parent_reference() {
async fn websocket_applies_deployment_routing_and_only_scopes_guardian_parent_reference() {
for owner in [Some(SELECTED_ACCOUNT), Some("acct_scope_old"), None] {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, SELECTED_ACCOUNT).await;
@@ -136,15 +136,35 @@ async fn account_correlation_preserves_turns_across_transports_and_account_switc
event.unwrap();
}
let (headers, body) = capture.await;
assert!(!headers.contains_key("traceparent"));
assert!(!headers.contains_key("tracestate"));
assert!(!headers.contains_key("sentry-trace"));
assert!(!headers.contains_key("baggage"));
assert_eq!(
headers["traceparent"],
"00-11111111111111111111111111111111-2222222222222222-01"
);
assert_eq!(headers["tracestate"], "vendor=source");
assert_eq!(headers["sentry-trace"], "source-trace");
assert_eq!(
headers
.get_all("baggage")
.iter()
.map(|value| value.to_str().unwrap())
.collect::<Vec<_>>(),
["sentry-environment=synthetic", "sentry-release=synthetic"]
);
let flat = &body["client_metadata"];
assert!(flat.get("ws_request_header_traceparent").is_none());
assert!(flat.get("ws_request_header_tracestate").is_none());
assert!(flat.get("ws_request_header_sentry-trace").is_none());
assert!(flat.get("ws_request_header_baggage").is_none());
assert_eq!(
flat["ws_request_header_traceparent"],
headers["traceparent"].to_str().unwrap()
);
assert_eq!(flat["ws_request_header_tracestate"], "vendor=source");
assert_eq!(flat["ws_request_header_sentry-trace"], "source-trace");
assert_eq!(
flat["ws_request_header_baggage"],
"sentry-environment=synthetic"
);
assert_eq!(
headers["x-codex-installation-id"].to_str().unwrap(),
flat["x-codex-installation-id"]
);
let header: Value =
serde_json::from_str(headers["x-codex-turn-metadata"].to_str().unwrap()).unwrap();
let full: Value =
@@ -163,8 +183,8 @@ async fn account_correlation_preserves_turns_across_transports_and_account_switc
assert_eq!(flat["x-client-turn-id"], "source-turn");
assert_eq!(flat["turn_id"], "source-turn");
for metadata in [&header, &full] {
assert!(metadata.get("sentry-trace").is_none());
assert!(metadata.get("baggage").is_none());
assert_eq!(metadata["sentry-trace"], "source-trace");
assert_eq!(metadata["baggage"], "sentry-environment=synthetic");
assert_eq!(metadata["turn_id"], "source-turn");
assert_eq!(metadata["parent_turn_id"], "source-turn");
assert_eq!(metadata["root_turn_id"], "source-turn");
@@ -20,6 +20,7 @@ mod search_model_access;
mod selection_retry;
mod session_binding;
mod timing;
mod turn_state;
mod upstream_adapter;
use std::collections::{BTreeMap, BTreeSet};
@@ -6009,7 +6010,7 @@ async fn opaque_provider_options_do_not_change_openai_account_selection() {
}
#[tokio::test]
async fn http_account_scoping_drops_downstream_installation_header() {
async fn http_account_scoping_replaces_downstream_installation_header() {
for owner in ["acct_scope_same", "acct_scope_old"] {
let raw = r#"{"installation_id":"client-installation","future":true}"#;
let request = capture_scoped_http_request(
@@ -6050,7 +6051,10 @@ async fn http_account_scoping_drops_downstream_installation_header() {
assert_ne!(installation_id, "client-installation");
assert!(uuid::Uuid::parse_str(installation_id).is_ok());
assert_eq!(body["installation_id"], installation_id);
assert!(captured_header_values(&request, "x-codex-installation-id").is_empty());
assert_eq!(
captured_header_values(&request, "x-codex-installation-id"),
vec![installation_id.as_bytes().to_vec()]
);
let headers = captured_header_values(&request, "x-codex-turn-metadata");
assert_eq!(headers.len(), 1);
for encoded in [
@@ -6068,7 +6072,7 @@ async fn http_account_scoping_drops_downstream_installation_header() {
}
#[tokio::test]
async fn websocket_account_scoping_drops_downstream_installation_header() {
async fn websocket_account_scoping_replaces_downstream_installation_header() {
for owner in ["acct_scope_same", "acct_scope_old"] {
let raw = r#"{"installation_id":"client-installation","future":true}"#;
let store = Arc::new(MemoryAccountStore::default());
@@ -6147,7 +6151,7 @@ async fn websocket_account_scoping_drops_downstream_installation_header() {
assert_ne!(installation_id, "client-installation");
assert!(uuid::Uuid::parse_str(installation_id).is_ok());
assert_eq!(body["installation_id"], installation_id);
assert!(!header.contains_key("x-codex-installation-id"));
assert_eq!(header["x-codex-installation-id"], installation_id);
for encoded in [
header["x-codex-turn-metadata"]
.to_str()
@@ -6219,7 +6223,10 @@ async fn same_account_scope_preserves_future_protocol_shapes() {
body.pointer("/client_metadata/x-codex-installation-id")
);
assert!(body.pointer("/client_metadata/installation_id").is_none());
assert!(captured_header_values(&request, "x-codex-installation-id").is_empty());
assert_eq!(
request.headers["x-codex-installation-id"].to_str().unwrap(),
body["client_metadata"]["x-codex-installation-id"]
);
}
#[tokio::test]
@@ -85,6 +85,13 @@ async fn independent_endpoints_send_opaque_headers_without_client_identity() {
let entries: Vec<_> = [
("x-future-business", "first"),
("x-future-business", "second"),
("x-codex-installation-id", "client-installation"),
("x-openai-account-routing-override", "client-route"),
("x-openai-fedramp", "true"),
("traceparent", "client-trace"),
("tracestate", "vendor=client"),
("sentry-trace", "sentry-client-trace"),
("baggage", "environment=synthetic"),
("authorization", "Bearer client-secret"),
("chatgpt-account-id", "client-account"),
("x-stainless-runtime", "client-runtime"),
@@ -134,6 +141,21 @@ async fn independent_endpoints_send_opaque_headers_without_client_identity() {
let requests = server.received_requests().await.unwrap();
let sent = requests.last().unwrap();
assert_eq!(sent.url.path(), endpoint);
uuid::Uuid::parse_str(sent.headers["x-codex-installation-id"].to_str().unwrap()).unwrap();
assert!(
!sent
.headers
.contains_key("x-openai-account-routing-override")
);
for (name, expected) in [
("x-openai-fedramp", "true"),
("traceparent", "client-trace"),
("tracestate", "vendor=client"),
("sentry-trace", "sentry-client-trace"),
("baggage", "environment=synthetic"),
] {
assert_eq!(sent.headers[name], expected);
}
if endpoint.ends_with("search") {
assert_scoped_search_body(&sent.body, &raw);
} else {
@@ -291,9 +291,9 @@ async fn privacy_header_change_isolates_websocket_then_reuses_stable_headers() {
let mut sockets = Vec::new();
let mut cache_keys = Vec::new();
let mut session_headers = Vec::new();
for (expected, requests) in [
("Bearer first-synthetic-token", 1),
("Bearer second-synthetic-token", 2),
for (expected, trace, requests) in [
("Bearer first-synthetic-token", "first-trace", 1),
("Bearer second-synthetic-token", "second-trace", 2),
] {
let (stream, _) = timeout(Duration::from_secs(5), listener.accept())
.await
@@ -302,8 +302,11 @@ async fn privacy_header_change_isolates_websocket_then_reuses_stable_headers() {
let mut socket =
crate::transport::accept_codex_test_websocket_with(stream, |request, response| {
assert_eq!(request.headers()["authorization"], expected);
assert!(!request.headers().contains_key("sentry-trace"));
assert!(!request.headers().contains_key("baggage"));
assert_eq!(request.headers()["sentry-trace"], trace);
assert_eq!(
request.headers()["baggage"],
format!("sentry-trace_id={trace}")
);
session_headers.push(request.headers()["session-id"].clone());
response.headers_mut().insert(
"sec-websocket-extensions",
@@ -0,0 +1,415 @@
//! 验证同轮全量请求回传的 token 归属、失败路径与账号状态隔离
use super::*;
const TOKEN: &str = "upstream-turn-state";
fn full_body() -> Value {
json!({
"model": "gpt-5.4", "store": false,
"input": [{"role": "user", "content": "hello"}],
"client_metadata": {
"session_id": "turn-state-session",
"thread_id": "turn-state-thread",
"turn_id": "turn-state-turn"
}
})
}
fn full_request(body: Value, token: Option<&str>, websocket: bool) -> Operation {
let mut protocol_context = Map::from_iter([
("use_websocket".to_owned(), json!(websocket)),
(
"session_id".to_owned(),
body["client_metadata"]["session_id"].clone(),
),
(
"thread_id".to_owned(),
body["client_metadata"]["thread_id"].clone(),
),
(
"turn_metadata".to_owned(),
json!(body["client_metadata"].to_string()),
),
]);
if let Some(token) = token {
protocol_context.insert("turn_state".to_owned(), json!(token));
protocol_context.insert(
"opaque_request_headers".to_owned(),
json!([["x-codex-turn-state", STANDARD.encode(token)]]),
);
}
Operation::Generate(GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object("openai", body.as_object().unwrap().clone())
.unwrap()
.with_context(protocol_context),
))
}
async fn collect_response(
provider: &Arc<CodexProvider>,
operation: Operation,
context: AttemptContext,
) -> Result<bool, gateway_core::error::ProviderError> {
let mut stream = provider
.clone()
.execute(planned_request("openai", operation), context)
.await?;
let mut observed_token = false;
while let Some(event) = stream.next().await {
let event = event?;
if let Some(observation) = event.response_observation() {
observed_token |= observation.client_headers().iter().any(|header| {
header.name() == "x-codex-turn-state" && header.value().as_ref() == TOKEN.as_bytes()
});
}
}
Ok(observed_token)
}
async fn http_fixture() -> (Arc<MemoryAccountStore>, MockServer, Arc<CodexProvider>) {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_session_affinity").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")
.insert_header("x-codex-turn-state", TOKEN)
.set_body_string(CAPTURE_COMPLETED_SSE),
)
.mount(&server)
.await;
let provider = provider_with_base_url(&store, server.uri());
(store, server, provider)
}
#[tokio::test]
async fn same_turn_full_http_request_preserves_observed_token_without_restoring_other_account_state()
{
let (_store, server, provider) = http_fixture().await;
assert!(
collect_response(
&provider,
full_request(full_body(), None, false),
context("req_turn_first", CancellationToken::new())
)
.await
.unwrap()
);
let mut body = full_body();
body["client_metadata"]["x-codex-turn-state"] = json!(TOKEN);
body["client_metadata"]["response_id"] = json!("unowned-response");
body["client_metadata"]["parent_response_id"] = json!("unowned-parent");
body["client_metadata"]["account_id"] = json!("unowned-account");
collect_response(
&provider,
full_request(body, Some(TOKEN), false),
context("req_turn_echo", CancellationToken::new()),
)
.await
.unwrap();
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(requests[1].headers["x-codex-turn-state"], TOKEN);
let body = captured_request_body(&requests[1]);
assert_eq!(body["client_metadata"]["x-codex-turn-state"], TOKEN);
assert!(body.get("previous_response_id").is_none());
for field in ["response_id", "parent_response_id", "account_id"] {
assert!(
body["client_metadata"].get(field).is_none(),
"{field} must remain scoped"
);
}
}
#[tokio::test]
async fn verified_header_echo_does_not_authorize_a_different_metadata_token() {
let (_store, server, provider) = http_fixture().await;
collect_response(
&provider,
full_request(full_body(), None, false),
context("req_turn_first", CancellationToken::new()),
)
.await
.unwrap();
let mut body = full_body();
body["client_metadata"]["x-codex-turn-state"] = json!("unobserved-token");
collect_response(
&provider,
full_request(body, Some(TOKEN), false),
context("req_turn_echo", CancellationToken::new()),
)
.await
.unwrap();
let requests = server.received_requests().await.unwrap();
assert_eq!(requests[1].headers["x-codex-turn-state"], TOKEN);
assert!(
captured_request_body(&requests[1])["client_metadata"]
.get("x-codex-turn-state")
.is_none()
);
}
#[tokio::test]
async fn turn_state_from_failed_http_response_is_owned_before_a_terminal_success() {
let (_store, server, provider) = http_fixture().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(ResponseTemplate::new(400)
.insert_header("x-codex-turn-state", TOKEN)
.set_body_json(json!({"error": {"type": "invalid_request_error", "message": "Invalid request."}})))
.up_to_n_times(1)
.with_priority(1)
.mount(&server).await;
assert!(
collect_response(
&provider,
full_request(full_body(), None, false),
context("req_turn_first", CancellationToken::new())
)
.await
.is_err()
);
collect_response(
&provider,
full_request(full_body(), Some(TOKEN), false),
context("req_turn_echo", CancellationToken::new()),
)
.await
.unwrap();
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(requests[1].headers["x-codex-turn-state"], TOKEN);
}
#[tokio::test]
async fn turn_state_proof_does_not_authorize_an_unowned_previous_response_id() {
let (_store, server, provider) = http_fixture().await;
collect_response(
&provider,
full_request(full_body(), None, false),
context("req_turn_first", CancellationToken::new()),
)
.await
.unwrap();
let mut body = full_body();
body["previous_response_id"] = json!("unowned-response");
let error = collect_response(
&provider,
full_request(body, Some(TOKEN), false),
context("req_turn_echo", CancellationToken::new()),
)
.await
.unwrap_err();
assert_eq!(
error
.client_visible_upstream_error()
.and_then(|detail| detail.code()),
Some("previous_response_not_found")
);
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[tokio::test]
async fn full_request_turn_state_requires_matching_client_account_thread_turn_and_token() {
for case in [
"new_turn",
"missing_turn",
"new_thread",
"new_session",
"new_key",
"new_account",
"unknown_token",
"conflicting_owner",
"expired",
"new_provider",
] {
let (store, server, provider) = http_fixture().await;
let first_context = if case == "expired" {
AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new("req_turn_first").unwrap(),
ClientApiKeyId::new("key_openai_contract").unwrap(),
),
NonZeroU32::new(1).unwrap(),
SystemTime::now() + Duration::from_secs(30),
account_policy().with_openai_session_affinity_ttl(Duration::ZERO),
AccountAttemptContext::new(BTreeSet::new(), None, None)
.with_account_scope(contract_account_scope()),
None,
CancellationToken::new(),
)
} else if case == "new_account" {
diagnostic_context("req_turn_first", "acct_session_affinity")
} else {
context("req_turn_first", CancellationToken::new())
};
collect_response(
&provider,
full_request(full_body(), None, false),
first_context,
)
.await
.unwrap();
let mut body = full_body();
let mut token = TOKEN;
let mut echo_context = context("req_turn_echo", CancellationToken::new());
let provider = if case == "new_provider" {
provider_with_base_url(&store, server.uri())
} else {
provider
};
match case {
"new_turn" => body["client_metadata"]["turn_id"] = json!("another-turn"),
"missing_turn" => {
body["client_metadata"]
.as_object_mut()
.unwrap()
.remove("turn_id");
}
"new_thread" => body["client_metadata"]["thread_id"] = json!("another-thread"),
"new_session" => {
body["client_metadata"]["session_id"] = json!("another-session");
echo_context = diagnostic_context("req_turn_echo", "acct_session_affinity");
}
"unknown_token" => token = "unobserved-token",
"new_account" => {
create_account(&store, "acct_scope_new").await;
echo_context = diagnostic_context("req_turn_echo", "acct_scope_new");
}
"conflicting_owner" => {
echo_context = context_with_state_owner("req_turn_echo", "acct_scope_old")
}
"new_key" => {
echo_context = AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new("req_turn_echo").unwrap(),
ClientApiKeyId::new("another-key").unwrap(),
),
NonZeroU32::new(1).unwrap(),
SystemTime::now() + Duration::from_secs(30),
account_policy(),
AccountAttemptContext::diagnostic(
BTreeSet::new(),
ProviderAccountId::new("acct_session_affinity").unwrap(),
None,
),
None,
CancellationToken::new(),
);
}
_ => {}
}
body["client_metadata"]["x-codex-turn-state"] = json!(token);
collect_response(
&provider,
full_request(body, Some(token), false),
echo_context,
)
.await
.unwrap_or_else(|error| panic!("{case}: {error:?}"));
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 2, "{case}");
assert!(
!requests[1].headers.contains_key("x-codex-turn-state"),
"{case}"
);
assert!(
captured_request_body(&requests[1])["client_metadata"]
.get("x-codex-turn-state")
.is_none(),
"{case}"
);
}
}
#[tokio::test]
async fn websocket_metadata_turn_state_survives_full_replay_after_completion_or_failure() {
for fail_first in [false, true] {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_session_affinity").await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
json!({
"type": "response.metadata", "headers": {"x-codex-turn-state": [TOKEN]}
})
.to_string()
.into(),
))
.await
.unwrap();
if fail_first {
websocket.close(None).await.unwrap();
let (stream, _) = listener.accept().await.unwrap();
websocket = accept_codex_test_websocket(stream).await;
} else {
websocket
.send(Message::Text(
CAPTURE_COMPLETED_SSE
.lines()
.find_map(|line| line.strip_prefix("data: "))
.unwrap()
.to_owned()
.into(),
))
.await
.unwrap();
}
let echo: Value = loop {
match websocket.next().await.unwrap().unwrap() {
Message::Text(text) => break serde_json::from_str(&text).unwrap(),
Message::Ping(payload) => websocket.send(Message::Pong(payload)).await.unwrap(),
Message::Close(_) => {
let (stream, _) = listener.accept().await.unwrap();
websocket = accept_codex_test_websocket(stream).await;
}
other => panic!("expected response.create, got {other:?}"),
}
};
websocket
.send(Message::Text(
CAPTURE_COMPLETED_SSE
.lines()
.find_map(|line| line.strip_prefix("data: "))
.unwrap()
.to_owned()
.into(),
))
.await
.unwrap();
echo
});
let provider = provider_with_base_url(&store, base_url);
let first = collect_response(
&provider,
full_request(full_body(), None, true),
context("req_turn_first", CancellationToken::new()),
)
.await;
assert_eq!(first.is_err(), fail_first);
collect_response(
&provider,
full_request(full_body(), Some(TOKEN), true),
context("req_turn_echo", CancellationToken::new()),
)
.await
.unwrap();
let echo = timeout(Duration::from_secs(5), server)
.await
.unwrap()
.unwrap();
assert_eq!(
echo["client_metadata"]["x-codex-turn-state"], TOKEN,
"failed first response: {fail_first}"
);
assert!(echo.get("previous_response_id").is_none());
}
}
@@ -428,6 +428,10 @@ fn live_protocol_headers() -> Vec<gateway_core::operation::ProviderHttpHeader> {
("traceparent", "client-trace"),
("tracestate", "client-state"),
("ws_request_header_traceparent", "client-ws-trace"),
("x-codex-installation-id", "foreign-installation"),
("X-OpenAI-Account-Routing-Override", "first-route"),
("x-openai-account-routing-override", "second-route"),
("x-openai-fedramp", "true"),
("x-codex-turn-state", "foreign-turn-state"),
("x-codex-routing-hint", "foreign-route"),
("openai-alpha", "live-test"),
@@ -452,6 +456,7 @@ fn assert_live_correlation(headers: &reqwest::header::HeaderMap) {
uuid::Uuid::parse_str(session).unwrap();
assert_eq!(headers["thread-id"], session);
assert_eq!(headers["openai-alpha"], "live-test");
assert_eq!(headers["x-openai-fedramp"], "true");
assert_eq!(
headers
.get_all("x-test-business")
@@ -465,9 +470,7 @@ fn assert_live_correlation(headers: &reqwest::header::HeaderMap) {
"x-oai-attestation",
"x-forwarded-for",
"x-stainless-runtime",
"traceparent",
"tracestate",
"ws_request_header_traceparent",
"x-openai-account-routing-override",
"x-codex-turn-state",
"x-codex-routing-hint",
] {
@@ -479,7 +482,14 @@ fn assert_live_correlation(headers: &reqwest::header::HeaderMap) {
assert_eq!(metadata["future"], true);
assert_ne!(metadata["installation_id"], "foreign-installation");
assert!(metadata.get("account_id").is_none());
assert!(metadata.get("traceparent").is_none());
assert_eq!(metadata["traceparent"], "client-trace");
assert_eq!(headers["traceparent"], "client-trace");
assert_eq!(headers["tracestate"], "client-state");
assert_eq!(headers["ws_request_header_traceparent"], "client-ws-trace");
assert_eq!(
headers["x-codex-installation-id"].to_str().unwrap(),
metadata["installation_id"]
);
}
#[tokio::test]
@@ -226,6 +226,7 @@ async fn backend_websocket_should_forward_context_headers_and_preserve_payload_f
("x-codex-parent-thread-id", "parent-456"),
("x-openai-subagent", "future_codex_mode"),
("x-openai-memgen-request", "true"),
("x-codex-installation-id", "install-123"),
("session-id", "cp_derived"),
] {
let value = &headers.iter().find(|(header, _)| header == name).unwrap().1;
@@ -240,7 +241,6 @@ async fn backend_websocket_should_forward_context_headers_and_preserve_payload_f
}
}
for forbidden in [
"x-codex-installation-id",
"x-codex-turn-id",
"x-openai-internal-codex-residency",
"accept",
@@ -365,8 +365,11 @@ async fn backend_http_should_send_codex_context_without_browser_headers() {
] {
assert!(header_names.iter().any(|name| name == required));
}
assert_eq!(
read_header_value(&raw_request, "x-codex-installation-id"),
Some("install-1")
);
for forbidden in [
"x-codex-installation-id",
"x-codex-turn-id",
"x-openai-internal-codex-residency",
"sec-ch-ua",
@@ -143,7 +143,7 @@ fn deployment_routing_survives_artifact_updates_and_profile_selection() {
}
#[tokio::test]
async fn model_transports_apply_configured_routing_and_discard_downstream_values() {
async fn model_transports_apply_deployment_routing_with_or_without_client_headers() {
use base64::{Engine as _, engine::general_purpose::STANDARD};
use gateway_core::operation::{GenerateRequest, ProtocolPayload};
@@ -178,7 +178,9 @@ async fn model_transports_apply_configured_routing_and_discard_downstream_values
[None, Some("NO_CONSTRAINT")],
),
] {
for websocket in [false, true] {
for (websocket, has_downstream_headers) in
[(false, false), (false, true), (true, false), (true, true)]
{
let (base_url, capture): (
String,
futures::future::BoxFuture<'static, reqwest::header::HeaderMap>,
@@ -237,6 +239,7 @@ async fn model_transports_apply_configured_routing_and_discard_downstream_values
);
let downstream_headers = HEADERS
.into_iter()
.filter(|_| has_downstream_headers)
.flat_map(|name| {
["downstream-first", "downstream-second"]
.map(|value| json!([name, STANDARD.encode(value)]))
@@ -270,7 +273,7 @@ async fn model_transports_apply_configured_routing_and_discard_downstream_values
.map(|value| value.to_str().unwrap())
.collect::<Vec<_>>(),
value.into_iter().collect::<Vec<_>>(),
"{document}, websocket={websocket}, {name}",
"{document}, websocket={websocket}, downstream={has_downstream_headers}, {name}",
);
}
}
+3 -2
View File
@@ -392,8 +392,9 @@ PostgreSQL/Redis 启动密码。日常校验使用 `config --quiet`
位置覆盖只影响 OpenAI 请求中受支持的位置与时区信息,不改变上下文日期、真实出口 IP、epoch 时间戳或系统时区。
`openai.residency` 和 `openai.account_routing_override` 是独立于客户端身份的启动配置,
分别指定 `x-openai-internal-codex-residency` 和 `x-openai-account-routing-override` 请求头。
两项均为可选字符串,填写值原样发送,不校验地域或路由枚举;省略或设为 `null` 时删除对应的下游请求头且不补发。
示例配置均填写 `us`,修改后重启应用生效。身份与位置字段见 [运行设置 API](../docs/api.md#8-运行设置)
两项均为可选字符串,配置值原样发送并覆盖客户端同名头,支持自定义地域或路由值。
省略或设为 `null` 时删除对应请求头。
修改后重启应用生效。身份与位置字段见 [运行设置 API](../docs/api.md#8-运行设置)
后台账号操作使用 Provider 自己的官方画像;发布资料刷新不改写用户选择或 `config.yaml`
### CPR 互联
+13 -7
View File
@@ -395,10 +395,11 @@ API Key 与 OAuth 共用模拟客户端画像(`User-Agent`、`originator`、`v
包括会话、线程、Lite 和其他业务扩展头。
上游认证只来自选中的账号;API Key 不携带 OAuth Cookie、ChatGPT 账号身份或下游的
`X-OpenAI-Actor-Authorization` 托管认证声明。
下游的 `x-openai-internal-codex-residency`、`x-openai-account-routing-override`、`x-openai-fedramp`
也不透传,地域、工作区路由与合规属性不能从原账号继承。
前两项仅按部署配置 `openai.residency`、`openai.account_routing_override` 的字符串值生成,
省略或 `null` 时不发送,配置说明见 [部署与运维](../deploy/README.md#启动后的运行设置)
`x-codex-installation-id` 使用选中账号已生成并持久化的安装 ID,与正文 `client_metadata` 中的安装身份一致。
`x-openai-internal-codex-residency` 和 `x-openai-account-routing-override` 分别由部署配置
`openai.residency`、`openai.account_routing_override` 控制,配置值原样发送并覆盖客户端同名头,
省略或 `null` 时删除对应请求头。配置说明见 [部署与运维](../deploy/README.md#启动后的运行设置)
`x-openai-fedramp` 按业务头透传,保留原始多值。
上述过滤描述原生转发;已信任插件的显式 header 改写可以覆盖账号与画像生成的默认头,
不按认证或会话字段名称拦截,边界见 [SDK 请求中间件](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#模型请求与-attempt)
@@ -409,8 +410,8 @@ Responses 也不透传 `x-stainless-*`、`Origin`、`Referer`、`sec-ch-ua*` 和
两者同时存在时仍优先使用 `session-id`。正文中的 `client_metadata.session_id`、
`prompt_cache_key` 不受这条请求头规则影响。
`thread-id`、turn metadata 等 Codex 协议字段及未知业务扩展不受下游环境头过滤规则影响;
出站移除下游的 `traceparent`、`tracestate`、`sentry-trace`、`baggage`,
以及 WS metadata 中对应的 `ws_request_header_*` 副本
`traceparent`、`tracestate`、`sentry-trace`、`baggage` 及 WS metadata 中对应的
`ws_request_header_*` 副本保持原值
会话、线程、窗口及父子线程引用在出站时按本次安装身份稳定映射为 UUID;
默认使用账号安装身份,同账号保留引用关系,换账号使用不同标识。HTTP 请求头、正文 metadata、WS 副本及
@@ -425,6 +426,11 @@ Images 的 `x-codex-image-turn-id` 和 Desktop 自动 Git 上下文的 `clientUs
续接 response ID、历史 item/call ID、业务正文和未知扩展不参与映射,
该处理减少显式追踪标识的跨账号复用,不保证会话内容或业务 metadata 无法关联
`x-codex-turn-state` 是上游返回的轮次路由 token,同一轮内应原样回传,开始新轮次时清除。
全量请求没有 `previous_response_id` 时,代理也会保留已确认属于当前 Client Key、账号、会话、线程和轮次的回传值;
未知归属、跨账号或跨轮次的值仍会清除。该证明只用于 turn state,不使其他账号状态字段成为可信续接上下文。
归属证明为进程内有界缓存,实例切换、重启、过期或淘汰后,缺少其他可信会话状态的请求按未知归属处理
#### 正文兼容
OpenAI Provider 的 Responses 上游编码会移除 Codex 不接受的顶层 `temperature`、`max_output_tokens` 和
@@ -911,7 +917,7 @@ CPR 默认路径为 `/v1/cpr/link`,使用同一数据面入口认证,不接
引导、sideband 与 hangup 的业务扩展头默认透传;认证、账号身份、下游环境与传输头遵守
[请求头与身份](#请求头与身份)边界。`X-Session-Id`、`Voice-Session-Id` 是 Live 业务会话标识,原样透传。
`Session-Id`、`Thread-Id` 等已识别追踪标识按选中账号的安装身份稳定映射,同账号各阶段保持一致,
换账号使用不同标识;客户端 trace 被剥离。创建请求可由 attempt 插件改写本次构造身份,后续 sideband / hangup 仍使用钉住账号的身份。
换账号使用不同标识,客户端 trace 原样透传。创建请求可由 attempt 插件改写本次构造身份,后续 sideband / hangup 仍使用钉住账号的身份。
Live 不继承 Responses 的路由提示与续接状态;turn metadata 中的原账号绑定信息清理后保留其他字段。
真实 call ID 保留用于钉住账号访问通话。HTTP 业务头保留原始多值字节,sideband 握手沿用 WebSocket 可编码的头值
+8 -1
View File
@@ -503,6 +503,11 @@ OpenAI Provider 按 Client Key、所选 OAuth 账号和父响应 ID 关联 WS
缺失或失效时按普通上游路由执行。transport 负责 Cookie 有效期和同 origin、同端点路径约束,
连接池按快照身份隔离审查连接;复用父连接不会重新计算 Cookie 有效期,Cookie 到期不强制关闭已建立的审查连接
OpenAI Provider 在交付响应头或 WS metadata 前记录 turn state 的归属证明,包括失败响应。
证明按 Client Key、账号、客户端会话、线程和轮次隔离,API Key 额外绑定凭据版本;只保存摘要与过期时间,
不保存 token 原值或正文。进程内最多 4096 项,保留时长复用请求的会话亲和策略,已验证回传与上游观测续期;
满额时清理过期项并淘汰最早过期项。证明缺失时保留既有未知归属处理,不参与选号或授权其他账号状态复用
OpenAI 的 OAuth 与 API Key 共用现有账号和事务。API Key 的 Base URL、密钥和传输策略属于 Provider 凭据 JSON,
随 credential revision 更新;普通详情只投影非敏感连接设置。API Key 客户端目录按账号、凭据版本和客户端版本隔离,
标准 `data` 模型列表通过通用画像输出,完整 Codex `models` 目录保留原生对象。通用账号层按 Provider 提交的
@@ -515,7 +520,7 @@ OpenAI 模型目录用于发现与原生 Responses 账号资格筛选,OAuth
- OpenAI 是透明边界。正文兼容、身份归一和显式请求策略之外的业务字段保留原始语义;
业务响应按原始字节转发,客户端错误兼容与原生续写额度恢复按明确例外处理,见 [OpenAI 透传](api.md#openai-透传)。
`transport/correlation` 在出站副本上按本次安装身份统一映射关联标识并剥离客户端 trace;
`transport/correlation` 在出站副本上按本次安装身份统一映射关联标识,客户端 trace 原样保留。
原生安装身份取自账号;插件的身份生成、持久化及关联字段改写通过既有中间件链路自行完成。
内部选号、亲和、连接池与本地诊断继续读取原始身份;具体字段边界见 [API 协议](api.md#请求头与身份)
canonical facts 从同一数据旁路提取,用于路由、恢复判断、观测和计费。
@@ -584,6 +589,8 @@ client,OIDC 的 JWKS 缓存与单飞归属对应出口状态。自动刷新提
代理独立保存和测试,通过 `outboundProxyId` 绑定账号;账号保留解析后的 URL 供 Provider 使用。
出口测试结果属于诊断信息,不作为选择、授权或导入绑定的准入条件。
双栈出口并发检测,延迟对应首选可用出口的 HTTP 探测耗时(IPv4 优先),不包含另一地址族失败的等待;
检测仍等待两个地址族完成或超时,以保留双栈可用性结果。两者均失败时,延迟为整体检测耗时。
代理启停由代理记录独立持有;关闭时保留绑定、连接配置和位置规则,账号执行投影改为直连,
不使用该代理的位置覆盖,重新启用后恢复原出口。
连接配置改变时在同一事务内同步关联账号并清除旧测试结果。
+10 -17
View File
@@ -1,23 +1,16 @@
# v3.39.0
# v3.39.1
## Windows 支持
## OpenAI 兼容性
- 新增 Windows x64 与 ARM64 发行包,静态链接 MSVC 运行库,解压后无需额外安装 VC++ Redistributable
- 支持 Windows 管理端在线更新、重启与回滚,处理运行中程序替换、重启交接及只读发行文件清理,并校验 Windows 特有的不安全归档路径
- 补充 Windows 二进制部署说明;运行前需准备 PostgreSQL 和 Redis,使用数据库备份时需提供匹配服务端版本的 `pg_dump`
- 修复同轮全量请求丢失 `x-codex-turn-state` 的问题:HTTP 与 WebSocket 请求可回传已确认归属的上游轮次 token,包括失败响应中收到的值;继续隔离不同 Client Key、账号、会话、线程和轮次,不放宽其他续接状态的校验
- 保留客户端的 `traceparent`、`tracestate`、`sentry-trace`、`baggage` 及对应 WebSocket metadata,`x-openai-fedramp` 按业务头原样透传并保留多值
- 出站 `x-codex-installation-id` 使用所选账号的持久安装身份,与正文中的安装身份一致;地域与账号路由头继续由部署配置控制,配置值覆盖客户端同名头,省略或 `null` 时不发送
## 插件设置
## 运行稳定性
- 敏感配置支持逐字段修改,补填一项不会清除其他已保存密钥;留空保留现有值,单项清除可在保存前撤销
- 设置弹窗直接展示各敏感字段的保存状态,支持清理目标版本不再使用的字段,并调整表单与 JSON 切换按钮的尺寸
- 配置提交发生版本竞争时,在既有有界重试预算内重新读取完整快照并准备扩展,减少创建或撤销 Key 等并发操作造成的短暂不可用;持续竞争或真实存储、扩展故障仍按原有规则暂停快照
- 修复删除代理返回版本冲突后事务锁未及时释放的问题,避免阻塞紧接着的代理编辑或删除
## 互联与部署
## 代理检测
- 请求记录、Dashboard 最近请求和账号维度诊断补充 CPR 互联徽标,按账号当前互联摘要展示,不改写请求历史快照
- 补充小内存部署的 PostgreSQL 预算、应用连接池和 Redis 持久化调优说明,包含生效、验证与回退步骤;默认配置保持不变
## 升级事项
- 插件管理 API 的 `secrets` 改为逐字段更新:字符串设置对应值,`null` 清除对应字段,未出现的字段保留;省略 `secrets` 或传 `{}` 均不修改密钥。依赖整份替换或空对象清空语义的调用方需调整
- 跨插件版本编辑优先使用目标版本保存的密钥快照,没有快照时沿用当前值,再应用本次字段变更
- Windows 首次部署请下载对应架构的完整发行包;插件安装包须匹配宿主系统与架构,部署前需验证实际插件运行与上游调用
- 双栈代理的延迟按首选可用出口的 HTTP 探测耗时显示,IPv4 优先,其次 IPv6,不再把另一地址族失败的等待计入成功出口延迟;检测仍等待两个地址族完成或超时,以保留完整可用性结果
+1 -1
View File
@@ -1 +1 @@
version: "3.39.0"
version: "3.39.1"