mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
[codex-http-state] extract generic per-surface HTTP state store [ci changed_files]
This commit is contained in:
Generated
+11
-1
@@ -2319,7 +2319,6 @@ dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"codex-utils-cargo-bin",
|
||||
"codex-utils-path",
|
||||
"codex-utils-rustls-provider",
|
||||
"eventsource-stream",
|
||||
"futures",
|
||||
@@ -3029,6 +3028,17 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-http-state"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-utils-path",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-image-generation-extension"
|
||||
version = "0.0.0"
|
||||
|
||||
@@ -37,6 +37,7 @@ members = [
|
||||
"core-plugins",
|
||||
"core-skills",
|
||||
"hooks",
|
||||
"http-state",
|
||||
"secrets",
|
||||
"exec",
|
||||
"file-system",
|
||||
@@ -177,6 +178,7 @@ codex-file-search = { path = "file-search" }
|
||||
codex-file-watcher = { path = "file-watcher" }
|
||||
codex-git-utils = { path = "git-utils" }
|
||||
codex-hooks = { path = "hooks" }
|
||||
codex-http-state = { path = "http-state" }
|
||||
codex-keyring-store = { path = "keyring-store" }
|
||||
codex-linux-sandbox = { path = "linux-sandbox" }
|
||||
codex-lmstudio = { path = "lmstudio" }
|
||||
|
||||
@@ -7,7 +7,6 @@ version.workspace = true
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
codex-utils-path = { workspace = true }
|
||||
eventsource-stream = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
http = { workspace = true }
|
||||
|
||||
@@ -3,7 +3,6 @@ mod chatgpt_hosts;
|
||||
mod custom_ca;
|
||||
mod default_client;
|
||||
mod error;
|
||||
mod native_integrity;
|
||||
mod request;
|
||||
mod retry;
|
||||
mod sse;
|
||||
@@ -27,12 +26,8 @@ pub use crate::default_client::CodexHttpClient;
|
||||
pub use crate::default_client::CodexRequestBuilder;
|
||||
pub use crate::error::StreamError;
|
||||
pub use crate::error::TransportError;
|
||||
pub use crate::native_integrity::INTEGRITY_STATE_HEADER_NAME;
|
||||
pub use crate::native_integrity::INTEGRITY_STATE_UPDATE_HEADER_NAME;
|
||||
pub use crate::native_integrity::NativeIntegrityStateContext;
|
||||
pub use crate::native_integrity::NativeIntegrityStateFile;
|
||||
pub use crate::native_integrity::NativeIntegrityStateStore;
|
||||
pub use crate::native_integrity::NativeIntegritySurface;
|
||||
pub const INTEGRITY_STATE_HEADER_NAME: &str = "X-OAI-IS";
|
||||
pub const INTEGRITY_STATE_UPDATE_HEADER_NAME: &str = "X-OAI-IS-Update";
|
||||
pub use crate::request::PreparedRequestBody;
|
||||
pub use crate::request::Request;
|
||||
pub use crate::request::RequestBody;
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
const STATE_N: &str = "ois1.a.b.c";
|
||||
const STATE_N_PLUS_ONE: &str = "ois1.d.e.f";
|
||||
|
||||
#[test]
|
||||
fn maps_app_server_client_names_to_bounded_surfaces() {
|
||||
assert_eq!(
|
||||
[
|
||||
"codex-tui",
|
||||
"codex_exec",
|
||||
"codex_vscode",
|
||||
"codex_desktop",
|
||||
"codex_desktop_ssh",
|
||||
"codex_remote_control",
|
||||
"third_party_client",
|
||||
]
|
||||
.map(NativeIntegritySurface::from_app_server_client_name),
|
||||
[
|
||||
NativeIntegritySurface::CodexTui,
|
||||
NativeIntegritySurface::CodexExec,
|
||||
NativeIntegritySurface::CodexVscode,
|
||||
NativeIntegritySurface::CodexDesktop,
|
||||
NativeIntegritySurface::CodexDesktopSsh,
|
||||
NativeIntegritySurface::CodexRemoteControl,
|
||||
NativeIntegritySurface::CodexCli,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stores_state_in_one_file_per_surface() {
|
||||
let codex_home = TempDir::new().expect("tempdir");
|
||||
let store = NativeIntegrityStateStore::new(codex_home.path().to_path_buf());
|
||||
|
||||
store
|
||||
.replace(NativeIntegritySurface::CodexCli, STATE_N.to_string())
|
||||
.expect("CLI state should store");
|
||||
store
|
||||
.replace(
|
||||
NativeIntegritySurface::CodexDesktop,
|
||||
STATE_N_PLUS_ONE.to_string(),
|
||||
)
|
||||
.expect("desktop state should store");
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.load(NativeIntegritySurface::CodexCli)
|
||||
.expect("CLI state should load")
|
||||
.expect("CLI state should exist"),
|
||||
NativeIntegrityStateFile {
|
||||
state: STATE_N.to_string(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.load(NativeIntegritySurface::CodexDesktop)
|
||||
.expect("desktop state should load")
|
||||
.expect("desktop state should exist"),
|
||||
NativeIntegrityStateFile {
|
||||
state: STATE_N_PLUS_ONE.to_string(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
store.state_path(NativeIntegritySurface::CodexCli),
|
||||
codex_home.path().join("state/codex_cli.json")
|
||||
);
|
||||
store
|
||||
.clear(NativeIntegritySurface::CodexDesktop)
|
||||
.expect("desktop state should clear");
|
||||
assert_eq!(
|
||||
store
|
||||
.load(NativeIntegritySurface::CodexDesktop)
|
||||
.expect("desktop state should load"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_and_store_rejects_a_stale_prior_value() {
|
||||
let codex_home = TempDir::new().expect("tempdir");
|
||||
let store = NativeIntegrityStateStore::new(codex_home.path().to_path_buf());
|
||||
store
|
||||
.replace(NativeIntegritySurface::CodexCli, STATE_N.to_string())
|
||||
.expect("state should store");
|
||||
|
||||
assert!(
|
||||
!store
|
||||
.compare_and_store(
|
||||
NativeIntegritySurface::CodexCli,
|
||||
"ois1.stale.state.value",
|
||||
STATE_N_PLUS_ONE.to_string(),
|
||||
)
|
||||
.expect("compare should succeed")
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.compare_and_store(
|
||||
NativeIntegritySurface::CodexCli,
|
||||
STATE_N,
|
||||
STATE_N_PLUS_ONE.to_string(),
|
||||
)
|
||||
.expect("compare should succeed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_integrity_state_envelopes() {
|
||||
assert!(is_valid_integrity_state_envelope(STATE_N));
|
||||
assert!(!is_valid_integrity_state_envelope(""));
|
||||
assert!(!is_valid_integrity_state_envelope("state"));
|
||||
assert!(!is_valid_integrity_state_envelope(&format!(" {STATE_N}")));
|
||||
assert!(!is_valid_integrity_state_envelope("ois1.a.b"));
|
||||
assert!(!is_valid_integrity_state_envelope(&format!(
|
||||
"ois1.a.b.{}",
|
||||
"c".repeat(MAX_INTEGRITY_STATE_ENVELOPE_BYTES + 1)
|
||||
)));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
name = "codex-http-state"
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-utils-path = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -10,19 +10,15 @@ use codex_utils_path::write_atomically;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
pub const INTEGRITY_STATE_HEADER_NAME: &str = "X-OAI-IS";
|
||||
pub const INTEGRITY_STATE_UPDATE_HEADER_NAME: &str = "X-OAI-IS-Update";
|
||||
const MAX_INTEGRITY_STATE_ENVELOPE_BYTES: usize = 2048;
|
||||
|
||||
const STATE_DIR_NAME: &str = "state";
|
||||
|
||||
/// Native Codex surface that owns one local integrity-state file.
|
||||
/// Native Codex surface that owns one local HTTP-state file.
|
||||
///
|
||||
/// Unknown app-server clients intentionally share the CLI state file. This
|
||||
/// preserves the default classification while first-party clients opt into a
|
||||
/// more specific surface by setting `clientInfo.name` during initialization.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum NativeIntegritySurface {
|
||||
pub enum HttpStateSurface {
|
||||
CodexCli,
|
||||
CodexTui,
|
||||
CodexExec,
|
||||
@@ -32,7 +28,7 @@ pub enum NativeIntegritySurface {
|
||||
CodexRemoteControl,
|
||||
}
|
||||
|
||||
impl NativeIntegritySurface {
|
||||
impl HttpStateSurface {
|
||||
pub fn from_app_server_client_name(client_name: &str) -> Self {
|
||||
match client_name {
|
||||
"codex-tui" => Self::CodexTui,
|
||||
@@ -58,55 +54,50 @@ impl NativeIntegritySurface {
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for NativeIntegritySurface {
|
||||
impl fmt::Display for HttpStateSurface {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct NativeIntegrityStateFile {
|
||||
pub state: String,
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct HttpStateFile {
|
||||
state: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NativeIntegrityStateStore {
|
||||
pub struct HttpStateStore {
|
||||
codex_home: PathBuf,
|
||||
}
|
||||
|
||||
impl NativeIntegrityStateStore {
|
||||
impl HttpStateStore {
|
||||
pub fn new(codex_home: PathBuf) -> Self {
|
||||
Self { codex_home }
|
||||
}
|
||||
|
||||
fn state_path(&self, surface: NativeIntegritySurface) -> PathBuf {
|
||||
fn state_path(&self, surface: HttpStateSurface) -> PathBuf {
|
||||
self.codex_home
|
||||
.join(STATE_DIR_NAME)
|
||||
.join(format!("{surface}.json"))
|
||||
}
|
||||
|
||||
pub fn load(
|
||||
&self,
|
||||
surface: NativeIntegritySurface,
|
||||
) -> io::Result<Option<NativeIntegrityStateFile>> {
|
||||
pub fn get(&self, surface: HttpStateSurface) -> io::Result<Option<String>> {
|
||||
let path = self.state_path(surface);
|
||||
let contents = match fs::read(&path) {
|
||||
Ok(contents) => contents,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let state_file: NativeIntegrityStateFile =
|
||||
let state_file: HttpStateFile =
|
||||
serde_json::from_slice(&contents).map_err(io::Error::other)?;
|
||||
validate_integrity_state_envelope(&state_file.state)?;
|
||||
Ok(Some(state_file))
|
||||
Ok(Some(state_file.state))
|
||||
}
|
||||
|
||||
pub fn replace(&self, surface: NativeIntegritySurface, state: String) -> io::Result<()> {
|
||||
validate_integrity_state_envelope(&state)?;
|
||||
self.write(surface, &NativeIntegrityStateFile { state })
|
||||
pub fn set(&self, surface: HttpStateSurface, state: String) -> io::Result<()> {
|
||||
self.write(surface, &HttpStateFile { state })
|
||||
}
|
||||
|
||||
pub fn clear(&self, surface: NativeIntegritySurface) -> io::Result<()> {
|
||||
pub fn clear(&self, surface: HttpStateSurface) -> io::Result<()> {
|
||||
match fs::remove_file(self.state_path(surface)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
@@ -119,29 +110,24 @@ impl NativeIntegrityStateStore {
|
||||
/// This is intentionally lock-free. Concurrent writers may both observe
|
||||
/// the same prior value, in which case atomic replacement keeps the file
|
||||
/// well-formed and the last writer wins.
|
||||
pub fn compare_and_store(
|
||||
pub fn compare_and_set(
|
||||
&self,
|
||||
surface: NativeIntegritySurface,
|
||||
surface: HttpStateSurface,
|
||||
expected_state: &str,
|
||||
next_state: String,
|
||||
) -> io::Result<bool> {
|
||||
validate_integrity_state_envelope(&next_state)?;
|
||||
let Some(current) = self.load(surface)? else {
|
||||
let Some(current) = self.get(surface)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
if current.state != expected_state {
|
||||
if current != expected_state {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.write(surface, &NativeIntegrityStateFile { state: next_state })?;
|
||||
self.write(surface, &HttpStateFile { state: next_state })?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn write(
|
||||
&self,
|
||||
surface: NativeIntegritySurface,
|
||||
state_file: &NativeIntegrityStateFile,
|
||||
) -> io::Result<()> {
|
||||
fn write(&self, surface: HttpStateSurface, state_file: &HttpStateFile) -> io::Result<()> {
|
||||
let path = self.state_path(surface);
|
||||
let contents = serde_json::to_string_pretty(state_file).map_err(io::Error::other)?;
|
||||
write_atomically(&path, &contents)
|
||||
@@ -155,24 +141,24 @@ impl NativeIntegrityStateStore {
|
||||
/// auth decorator, so clones of the model client stay in sync without moving
|
||||
/// in-flight rotations to another surface.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NativeIntegrityStateContext {
|
||||
store: NativeIntegrityStateStore,
|
||||
surface: Arc<RwLock<NativeIntegritySurface>>,
|
||||
pub struct HttpStateContext {
|
||||
store: HttpStateStore,
|
||||
surface: Arc<RwLock<HttpStateSurface>>,
|
||||
}
|
||||
|
||||
impl NativeIntegrityStateContext {
|
||||
pub fn new(codex_home: PathBuf, surface: NativeIntegritySurface) -> Self {
|
||||
impl HttpStateContext {
|
||||
pub fn new(codex_home: PathBuf, surface: HttpStateSurface) -> Self {
|
||||
Self {
|
||||
store: NativeIntegrityStateStore::new(codex_home),
|
||||
store: HttpStateStore::new(codex_home),
|
||||
surface: Arc::new(RwLock::new(surface)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn surface(&self) -> NativeIntegritySurface {
|
||||
pub fn surface(&self) -> HttpStateSurface {
|
||||
*self.surface.read().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
pub fn set_surface(&self, surface: NativeIntegritySurface) -> bool {
|
||||
pub fn set_surface(&self, surface: HttpStateSurface) -> bool {
|
||||
let mut selected_surface = self.surface.write().unwrap_or_else(PoisonError::into_inner);
|
||||
if *selected_surface == surface {
|
||||
return false;
|
||||
@@ -181,60 +167,20 @@ impl NativeIntegrityStateContext {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn load_for_surface(
|
||||
&self,
|
||||
surface: NativeIntegritySurface,
|
||||
) -> io::Result<Option<NativeIntegrityStateFile>> {
|
||||
self.store.load(surface)
|
||||
pub fn get_for_surface(&self, surface: HttpStateSurface) -> io::Result<Option<String>> {
|
||||
self.store.get(surface)
|
||||
}
|
||||
|
||||
pub fn compare_and_store_for_surface(
|
||||
pub fn compare_and_set_for_surface(
|
||||
&self,
|
||||
surface: NativeIntegritySurface,
|
||||
surface: HttpStateSurface,
|
||||
expected_state: &str,
|
||||
next_state: String,
|
||||
) -> io::Result<bool> {
|
||||
self.store
|
||||
.compare_and_store(surface, expected_state, next_state)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_integrity_state_envelope(value: &str) -> bool {
|
||||
if value.is_empty() || value.len() > MAX_INTEGRITY_STATE_ENVELOPE_BYTES || value.trim() != value
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut parts = value.split('.');
|
||||
if parts.next() != Some("ois1") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let valid_part = |part: &str| {
|
||||
!part.is_empty()
|
||||
&& part
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
|
||||
};
|
||||
|
||||
matches!(
|
||||
(parts.next(), parts.next(), parts.next(), parts.next()),
|
||||
(Some(header), Some(nonce), Some(ciphertext), None)
|
||||
if valid_part(header) && valid_part(nonce) && valid_part(ciphertext)
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_integrity_state_envelope(value: &str) -> io::Result<()> {
|
||||
if is_valid_integrity_state_envelope(value) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"integrity state envelope is malformed",
|
||||
))
|
||||
.compare_and_set(surface, expected_state, next_state)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "native_integrity_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,100 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
const STATE_N: &str = "ois1.a.b.c";
|
||||
const STATE_N_PLUS_ONE: &str = "ois1.d.e.f";
|
||||
|
||||
#[test]
|
||||
fn maps_app_server_client_names_to_bounded_surfaces() {
|
||||
assert_eq!(
|
||||
[
|
||||
"codex-tui",
|
||||
"codex_exec",
|
||||
"codex_vscode",
|
||||
"codex_desktop",
|
||||
"codex_desktop_ssh",
|
||||
"codex_remote_control",
|
||||
"third_party_client",
|
||||
]
|
||||
.map(HttpStateSurface::from_app_server_client_name),
|
||||
[
|
||||
HttpStateSurface::CodexTui,
|
||||
HttpStateSurface::CodexExec,
|
||||
HttpStateSurface::CodexVscode,
|
||||
HttpStateSurface::CodexDesktop,
|
||||
HttpStateSurface::CodexDesktopSsh,
|
||||
HttpStateSurface::CodexRemoteControl,
|
||||
HttpStateSurface::CodexCli,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stores_state_in_one_file_per_surface() {
|
||||
let codex_home = TempDir::new().expect("tempdir");
|
||||
let store = HttpStateStore::new(codex_home.path().to_path_buf());
|
||||
|
||||
store
|
||||
.set(HttpStateSurface::CodexCli, STATE_N.to_string())
|
||||
.expect("CLI state should store");
|
||||
store
|
||||
.set(HttpStateSurface::CodexDesktop, STATE_N_PLUS_ONE.to_string())
|
||||
.expect("desktop state should store");
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.get(HttpStateSurface::CodexCli)
|
||||
.expect("CLI state should load")
|
||||
.expect("CLI state should exist"),
|
||||
STATE_N
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.get(HttpStateSurface::CodexDesktop)
|
||||
.expect("desktop state should load")
|
||||
.expect("desktop state should exist"),
|
||||
STATE_N_PLUS_ONE
|
||||
);
|
||||
assert_eq!(
|
||||
store.state_path(HttpStateSurface::CodexCli),
|
||||
codex_home.path().join("state/codex_cli.json")
|
||||
);
|
||||
store
|
||||
.clear(HttpStateSurface::CodexDesktop)
|
||||
.expect("desktop state should clear");
|
||||
assert_eq!(
|
||||
store
|
||||
.get(HttpStateSurface::CodexDesktop)
|
||||
.expect("desktop state should load"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_and_set_rejects_a_stale_prior_value() {
|
||||
let codex_home = TempDir::new().expect("tempdir");
|
||||
let store = HttpStateStore::new(codex_home.path().to_path_buf());
|
||||
store
|
||||
.set(HttpStateSurface::CodexCli, STATE_N.to_string())
|
||||
.expect("state should store");
|
||||
|
||||
assert!(
|
||||
!store
|
||||
.compare_and_set(
|
||||
HttpStateSurface::CodexCli,
|
||||
"ois1.stale.state.value",
|
||||
STATE_N_PLUS_ONE.to_string(),
|
||||
)
|
||||
.expect("compare should succeed")
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.compare_and_set(
|
||||
HttpStateSurface::CodexCli,
|
||||
STATE_N,
|
||||
STATE_N_PLUS_ONE.to_string(),
|
||||
)
|
||||
.expect("compare should succeed")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user