mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
[codex-client] add native integrity state store [ci changed_files]
This commit is contained in:
@@ -10,6 +10,18 @@ pub fn is_allowed_chatgpt_host(host: &str) -> bool {
|
||||
.any(|suffix| host.ends_with(suffix))
|
||||
}
|
||||
|
||||
/// Returns whether `url` is an HTTPS or secure WebSocket ChatGPT URL that Codex
|
||||
/// may treat as first-party traffic.
|
||||
pub fn is_allowed_chatgpt_request_url(url: &str) -> bool {
|
||||
let Ok(url) = reqwest::Url::parse(url) else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(url.scheme(), "https" | "wss") {
|
||||
return false;
|
||||
}
|
||||
url.host_str().is_some_and(is_allowed_chatgpt_host)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -36,4 +48,25 @@ mod tests {
|
||||
assert!(!is_allowed_chatgpt_host(host));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_secure_chatgpt_request_urls() {
|
||||
for url in [
|
||||
"https://chatgpt.com/backend-api/codex/responses",
|
||||
"https://preview.chatgpt.com/backend-api/codex/models",
|
||||
"wss://chatgpt-staging.com/backend-api/codex/responses",
|
||||
] {
|
||||
assert!(is_allowed_chatgpt_request_url(url));
|
||||
}
|
||||
|
||||
for url in [
|
||||
"http://chatgpt.com/backend-api/codex/responses",
|
||||
"ws://chatgpt.com/backend-api/codex/responses",
|
||||
"https://api.openai.com/v1/responses",
|
||||
"https://chatgpt.com.evil.example/backend-api",
|
||||
"not a url",
|
||||
] {
|
||||
assert!(!is_allowed_chatgpt_request_url(url));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ mod chatgpt_hosts;
|
||||
mod custom_ca;
|
||||
mod default_client;
|
||||
mod error;
|
||||
mod native_integrity;
|
||||
mod request;
|
||||
mod retry;
|
||||
mod sse;
|
||||
@@ -11,6 +12,7 @@ mod transport;
|
||||
|
||||
pub use crate::chatgpt_cloudflare_cookies::with_chatgpt_cloudflare_cookie_store;
|
||||
pub use crate::chatgpt_hosts::is_allowed_chatgpt_host;
|
||||
pub use crate::chatgpt_hosts::is_allowed_chatgpt_request_url;
|
||||
pub use crate::custom_ca::BuildCustomCaTransportError;
|
||||
/// Test-only subprocess hook for custom CA coverage.
|
||||
///
|
||||
@@ -25,6 +27,12 @@ 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 use crate::request::PreparedRequestBody;
|
||||
pub use crate::request::Request;
|
||||
pub use crate::request::RequestBody;
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
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;
|
||||
|
||||
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.
|
||||
///
|
||||
/// 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 {
|
||||
CodexCli,
|
||||
CodexTui,
|
||||
CodexExec,
|
||||
CodexVscode,
|
||||
CodexDesktop,
|
||||
CodexDesktopSsh,
|
||||
CodexRemoteControl,
|
||||
}
|
||||
|
||||
impl NativeIntegritySurface {
|
||||
pub fn from_app_server_client_name(client_name: &str) -> Self {
|
||||
match client_name {
|
||||
"codex-tui" => Self::CodexTui,
|
||||
"codex_exec" => Self::CodexExec,
|
||||
"codex_vscode" => Self::CodexVscode,
|
||||
"codex_desktop" => Self::CodexDesktop,
|
||||
"codex_desktop_ssh" => Self::CodexDesktopSsh,
|
||||
"codex_remote_control" => Self::CodexRemoteControl,
|
||||
_ => 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 NativeIntegritySurface {
|
||||
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(Clone, Debug)]
|
||||
pub struct NativeIntegrityStateStore {
|
||||
codex_home: PathBuf,
|
||||
}
|
||||
|
||||
impl NativeIntegrityStateStore {
|
||||
pub fn new(codex_home: PathBuf) -> Self {
|
||||
Self { codex_home }
|
||||
}
|
||||
|
||||
fn state_path(&self, surface: NativeIntegritySurface) -> PathBuf {
|
||||
self.codex_home
|
||||
.join(STATE_DIR_NAME)
|
||||
.join(format!("{surface}.json"))
|
||||
}
|
||||
|
||||
pub fn load(
|
||||
&self,
|
||||
surface: NativeIntegritySurface,
|
||||
) -> io::Result<Option<NativeIntegrityStateFile>> {
|
||||
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 =
|
||||
serde_json::from_slice(&contents).map_err(io::Error::other)?;
|
||||
validate_integrity_state_envelope(&state_file.state)?;
|
||||
Ok(Some(state_file))
|
||||
}
|
||||
|
||||
pub fn replace(&self, surface: NativeIntegritySurface, state: String) -> io::Result<()> {
|
||||
validate_integrity_state_envelope(&state)?;
|
||||
self.write(surface, &NativeIntegrityStateFile { state })
|
||||
}
|
||||
|
||||
pub fn clear(&self, surface: NativeIntegritySurface) -> 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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores `next_state` only if the local file still contains `expected_state`.
|
||||
///
|
||||
/// 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(
|
||||
&self,
|
||||
surface: NativeIntegritySurface,
|
||||
expected_state: &str,
|
||||
next_state: String,
|
||||
) -> io::Result<bool> {
|
||||
validate_integrity_state_envelope(&next_state)?;
|
||||
let Some(current) = self.load(surface)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
if current.state != expected_state {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.write(surface, &NativeIntegrityStateFile { state: next_state })?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn write(
|
||||
&self,
|
||||
surface: NativeIntegritySurface,
|
||||
state_file: &NativeIntegrityStateFile,
|
||||
) -> 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 NativeIntegrityStateContext {
|
||||
store: NativeIntegrityStateStore,
|
||||
surface: Arc<RwLock<NativeIntegritySurface>>,
|
||||
}
|
||||
|
||||
impl NativeIntegrityStateContext {
|
||||
pub fn new(codex_home: PathBuf, surface: NativeIntegritySurface) -> Self {
|
||||
Self {
|
||||
store: NativeIntegrityStateStore::new(codex_home),
|
||||
surface: Arc::new(RwLock::new(surface)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn surface(&self) -> NativeIntegritySurface {
|
||||
*self.surface.read().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
pub fn set_surface(&self, surface: NativeIntegritySurface) -> 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 load_for_surface(
|
||||
&self,
|
||||
surface: NativeIntegritySurface,
|
||||
) -> io::Result<Option<NativeIntegrityStateFile>> {
|
||||
self.store.load(surface)
|
||||
}
|
||||
|
||||
pub fn compare_and_store_for_surface(
|
||||
&self,
|
||||
surface: NativeIntegritySurface,
|
||||
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",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "native_integrity_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,120 @@
|
||||
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)
|
||||
)));
|
||||
}
|
||||
Reference in New Issue
Block a user