diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f7e6ed5de3..94a23926d3 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3035,6 +3035,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "codex-http-state" +version = "0.0.0" +dependencies = [ + "codex-utils-path", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "uuid", +] + [[package]] name = "codex-image-generation-extension" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 59d161958d..28714b73be 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -38,6 +38,7 @@ members = [ "core-plugins", "core-skills", "hooks", + "http-state", "secrets", "exec", "file-system", diff --git a/codex-rs/http-state/BUILD.bazel b/codex-rs/http-state/BUILD.bazel new file mode 100644 index 0000000000..acd781b727 --- /dev/null +++ b/codex-rs/http-state/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "http-state", + crate_name = "codex_http_state", +) diff --git a/codex-rs/http-state/Cargo.toml b/codex-rs/http-state/Cargo.toml new file mode 100644 index 0000000000..a0d5c3a6d0 --- /dev/null +++ b/codex-rs/http-state/Cargo.toml @@ -0,0 +1,21 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-http-state" +version.workspace = true + +[lib] +doctest = false + +[dependencies] +codex-utils-path = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +uuid = { workspace = true, features = ["v4"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/codex-rs/http-state/src/http_state_tests.rs b/codex-rs/http-state/src/http_state_tests.rs new file mode 100644 index 0000000000..e54378b90f --- /dev/null +++ b/codex-rs/http-state/src/http_state_tests.rs @@ -0,0 +1,138 @@ +use super::*; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +const STATE_N: &str = "state-n"; +const STATE_N_PLUS_ONE: &str = "state-n-plus-one"; + +#[test] +fn maps_app_server_client_names_to_bounded_surfaces() { + assert_eq!( + [ + "codex_cli", + "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::CodexCli, + HttpStateSurface::CodexTui, + HttpStateSurface::CodexExec, + HttpStateSurface::CodexVscode, + HttpStateSurface::CodexDesktop, + HttpStateSurface::CodexDesktopSsh, + HttpStateSurface::CodexRemoteControl, + HttpStateSurface::CodexCli, + ] + ); + assert_eq!( + HttpStateSurface::try_from_app_server_client_name("third_party_client"), + None + ); +} + +#[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, + "stale-state", + 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") + ); +} + +#[test] +fn clear_hides_a_stale_rotation_written_afterward() { + 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"); + let stale_generation = store + .generation(HttpStateSurface::CodexCli) + .expect("generation should load"); + + store + .clear(HttpStateSurface::CodexCli) + .expect("state should clear"); + store + .write_state( + HttpStateSurface::CodexCli, + &HttpStateFile { + state: STATE_N_PLUS_ONE.to_string(), + generation: stale_generation, + }, + ) + .expect("stale rotation should write"); + + assert_eq!( + store + .get(HttpStateSurface::CodexCli) + .expect("state should load"), + None + ); +} diff --git a/codex-rs/http-state/src/lib.rs b/codex-rs/http-state/src/lib.rs new file mode 100644 index 0000000000..61b69fbbc0 --- /dev/null +++ b/codex-rs/http-state/src/lib.rs @@ -0,0 +1,257 @@ +use std::fmt; +use std::fs; +use std::io; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::PoisonError; +use std::sync::RwLock; + +use codex_utils_path::write_atomically; +use serde::Deserialize; +use serde::Serialize; +use uuid::Uuid; + +const STATE_DIR_NAME: &str = "state"; + +/// 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 HttpStateSurface { + CodexCli, + CodexTui, + CodexExec, + CodexVscode, + CodexDesktop, + CodexDesktopSsh, + CodexRemoteControl, +} + +impl HttpStateSurface { + pub fn try_from_app_server_client_name(client_name: &str) -> Option { + match client_name { + "codex_cli" => Some(Self::CodexCli), + "codex-tui" => Some(Self::CodexTui), + "codex_exec" => Some(Self::CodexExec), + "codex_vscode" => Some(Self::CodexVscode), + "codex_desktop" => Some(Self::CodexDesktop), + "codex_desktop_ssh" => Some(Self::CodexDesktopSsh), + "codex_remote_control" => Some(Self::CodexRemoteControl), + _ => None, + } + } + + pub fn from_app_server_client_name(client_name: &str) -> Self { + Self::try_from_app_server_client_name(client_name).unwrap_or(Self::CodexCli) + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::CodexCli => "codex_cli", + Self::CodexTui => "codex_tui", + Self::CodexExec => "codex_exec", + Self::CodexVscode => "codex_vscode", + Self::CodexDesktop => "codex_desktop", + Self::CodexDesktopSsh => "codex_desktop_ssh", + Self::CodexRemoteControl => "codex_remote_control", + } + } +} + +impl fmt::Display for HttpStateSurface { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +#[derive(Deserialize, Serialize)] +struct HttpStateFile { + state: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + generation: Option, +} + +#[derive(Deserialize, Serialize)] +struct HttpStateGenerationFile { + generation: String, +} + +#[derive(Clone, Debug)] +pub struct HttpStateStore { + codex_home: PathBuf, +} + +impl HttpStateStore { + pub fn new(codex_home: PathBuf) -> Self { + Self { codex_home } + } + + fn state_path(&self, surface: HttpStateSurface) -> PathBuf { + self.codex_home + .join(STATE_DIR_NAME) + .join(format!("{surface}.json")) + } + + fn generation_path(&self, surface: HttpStateSurface) -> PathBuf { + self.codex_home + .join(STATE_DIR_NAME) + .join(format!("{surface}.generation.json")) + } + + pub fn get(&self, surface: HttpStateSurface) -> io::Result> { + let Some(state_file) = self.read_state(surface)? else { + return Ok(None); + }; + if state_file.generation != self.generation(surface)? { + return Ok(None); + } + Ok(Some(state_file.state)) + } + + pub fn set(&self, surface: HttpStateSurface, state: String) -> io::Result<()> { + let generation = self.generation(surface)?; + self.write_state(surface, &HttpStateFile { state, generation }) + } + + pub fn clear(&self, surface: HttpStateSurface) -> io::Result<()> { + self.write_generation( + surface, + &HttpStateGenerationFile { + generation: Uuid::new_v4().to_string(), + }, + )?; + self.remove_state(surface) + } + + /// Stores `next_state` only if the local file still contains `expected_state`. + /// + /// This is intentionally lock-free. Concurrent rotations within one + /// generation may both observe the same prior value, in which case atomic + /// replacement keeps the file well-formed and the last writer wins. A + /// concurrent clear changes the generation so stale writes are ignored. + pub fn compare_and_set( + &self, + surface: HttpStateSurface, + expected_state: &str, + next_state: String, + ) -> io::Result { + let Some(current) = self.read_state(surface)? else { + return Ok(false); + }; + let generation = self.generation(surface)?; + if current.generation != generation || current.state != expected_state { + return Ok(false); + } + + self.write_state( + surface, + &HttpStateFile { + state: next_state, + generation: generation.clone(), + }, + )?; + Ok(self.generation(surface)? == generation) + } + + fn read_state(&self, surface: HttpStateSurface) -> io::Result> { + 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: HttpStateFile = + serde_json::from_slice(&contents).map_err(io::Error::other)?; + Ok(Some(state_file)) + } + + fn remove_state(&self, surface: HttpStateSurface) -> io::Result<()> { + match fs::remove_file(self.state_path(surface)) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } + } + + fn generation(&self, surface: HttpStateSurface) -> io::Result> { + let path = self.generation_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 generation_file: HttpStateGenerationFile = + serde_json::from_slice(&contents).map_err(io::Error::other)?; + Ok(Some(generation_file.generation)) + } + + fn write_state(&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) + } + + fn write_generation( + &self, + surface: HttpStateSurface, + generation_file: &HttpStateGenerationFile, + ) -> io::Result<()> { + let path = self.generation_path(surface); + let contents = serde_json::to_string_pretty(generation_file).map_err(io::Error::other)?; + write_atomically(&path, &contents) + } +} + +/// Shared surface selection for one native Codex client session. +/// +/// App-server clients may update the selected surface after the model client is +/// constructed. Requests snapshot the current value when they create their +/// auth decorator, so clones of the model client stay in sync without moving +/// in-flight rotations to another surface. +#[derive(Clone, Debug)] +pub struct HttpStateContext { + store: HttpStateStore, + surface: Arc>, +} + +impl HttpStateContext { + pub fn new(codex_home: PathBuf, surface: HttpStateSurface) -> Self { + Self { + store: HttpStateStore::new(codex_home), + surface: Arc::new(RwLock::new(surface)), + } + } + + pub fn surface(&self) -> HttpStateSurface { + *self.surface.read().unwrap_or_else(PoisonError::into_inner) + } + + 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; + } + *selected_surface = surface; + true + } + + pub fn get_for_surface(&self, surface: HttpStateSurface) -> io::Result> { + self.store.get(surface) + } + + pub fn compare_and_set_for_surface( + &self, + surface: HttpStateSurface, + expected_state: &str, + next_state: String, + ) -> io::Result { + self.store + .compare_and_set(surface, expected_state, next_state) + } +} + +#[cfg(test)] +#[path = "http_state_tests.rs"] +mod tests;