Skip to main content

tokn_headers/schemas/personas/
codex_cli.rs

1//! Headers emitted by the Codex CLI clients (`codex_exec`, `codex-tui`).
2//!
3//! Field set derived from the inbound real-world matrix. Codex sends several
4//! transport-class headers using lowercase, no-prefix names (`originator`,
5//! `version`, `session_id`, `thread_id`); these are kept verbatim rather
6//! than canonicalised because that's what the upstream chatgpt.com endpoint
7//! expects.
8//!
9//! The captured matrix contains multiple transport shapes:
10//! `chatgpt.com` websocket upgrades, local/router SSE POSTs, and
11//! browser-context account calls. A single typed schema therefore needs to
12//! accept endpoint-specific optional headers rather than assuming one
13//! normalized baseline.
14
15use crate::error::Error;
16use crate::keys;
17use crate::map::HeaderMap;
18use crate::name::HeaderName;
19use crate::schema::{from_inbound_or, opt_from_inbound, optional, put, put_opt, required, HeaderSchema};
20use crate::vars::TemplateVars;
21use serde::{Deserialize, Serialize};
22use smol_str::SmolStr;
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct CodexCliHeaders {
26  // Always present
27  #[serde(rename = "User-Agent")]
28  pub user_agent: SmolStr,
29  #[serde(rename = "Authorization")]
30  pub authorization: SmolStr,
31  /// Optional. NEVER stamped from a persona default: the persona-default
32  /// host (e.g. `chatgpt.com`) is wrong for any other upstream and would
33  /// cause edge/WAF rejections (TLS SNI vs HTTP Host mismatch) when it
34  /// leaks into Send. `build` only sets this from inbound traffic;
35  /// outbound transport derives `Host` from the URL.
36  #[serde(rename = "Host", skip_serializing_if = "Option::is_none")]
37  pub host: Option<SmolStr>,
38  #[serde(rename = "Accept", skip_serializing_if = "Option::is_none")]
39  pub accept: Option<SmolStr>,
40  #[serde(rename = "Connection", skip_serializing_if = "Option::is_none")]
41  pub connection: Option<SmolStr>,
42  #[serde(rename = "Upgrade", skip_serializing_if = "Option::is_none")]
43  pub upgrade: Option<SmolStr>,
44  #[serde(rename = "originator", skip_serializing_if = "Option::is_none")]
45  pub originator: Option<SmolStr>,
46  #[serde(rename = "chatgpt-account-id", skip_serializing_if = "Option::is_none")]
47  pub chatgpt_account_id: Option<SmolStr>,
48  #[serde(rename = "version", skip_serializing_if = "Option::is_none")]
49  pub version: Option<SmolStr>,
50
51  // Body framing — present on POSTs (responses, analytics-events), absent on
52  // GETs (models, plugins/featured).
53  #[serde(rename = "Content-Type", skip_serializing_if = "Option::is_none")]
54  pub content_type: Option<SmolStr>,
55  #[serde(rename = "Content-Length", skip_serializing_if = "Option::is_none")]
56  pub content_length: Option<SmolStr>,
57
58  // Responses-endpoint specific
59  #[serde(rename = "session_id", skip_serializing_if = "Option::is_none")]
60  pub session_id: Option<SmolStr>,
61  #[serde(rename = "thread_id", skip_serializing_if = "Option::is_none")]
62  pub thread_id: Option<SmolStr>,
63  #[serde(rename = "x-client-request-id", skip_serializing_if = "Option::is_none")]
64  pub client_request_id: Option<SmolStr>,
65  #[serde(rename = "x-codex-window-id", skip_serializing_if = "Option::is_none")]
66  pub codex_window_id: Option<SmolStr>,
67  #[serde(rename = "x-codex-beta-features", skip_serializing_if = "Option::is_none")]
68  pub codex_beta_features: Option<SmolStr>,
69  #[serde(rename = "x-codex-turn-metadata", skip_serializing_if = "Option::is_none")]
70  pub codex_turn_metadata: Option<SmolStr>,
71  #[serde(rename = "OpenAI-Beta", skip_serializing_if = "Option::is_none")]
72  pub openai_beta: Option<SmolStr>,
73  #[serde(rename = "X-Request-Id", skip_serializing_if = "Option::is_none")]
74  pub request_id: Option<SmolStr>,
75  #[serde(rename = "Sec-WebSocket-Extensions", skip_serializing_if = "Option::is_none")]
76  pub sec_websocket_extensions: Option<SmolStr>,
77  #[serde(rename = "Sec-WebSocket-Key", skip_serializing_if = "Option::is_none")]
78  pub sec_websocket_key: Option<SmolStr>,
79  #[serde(rename = "Sec-WebSocket-Version", skip_serializing_if = "Option::is_none")]
80  pub sec_websocket_version: Option<SmolStr>,
81
82  // Browser-context state
83  #[serde(rename = "Cookie", skip_serializing_if = "Option::is_none")]
84  pub cookie: Option<SmolStr>,
85}
86
87impl HeaderSchema for CodexCliHeaders {
88  fn parse(map: &HeaderMap) -> Result<Self, Error> {
89    Ok(Self {
90      user_agent: required(map, &keys::USER_AGENT)?,
91      authorization: required(map, &keys::AUTHORIZATION)?,
92      host: optional(map, &keys::HOST),
93      accept: optional(map, &keys::ACCEPT),
94      connection: optional(map, &keys::CONNECTION),
95      upgrade: optional(map, &keys::UPGRADE),
96      originator: optional(map, &keys::ORIGINATOR),
97      chatgpt_account_id: optional(map, &keys::CHATGPT_ACCOUNT_ID),
98      version: optional(map, &keys::VERSION),
99      content_type: optional(map, &keys::CONTENT_TYPE),
100      content_length: optional(map, &keys::CONTENT_LENGTH),
101      session_id: optional(map, &keys::SESSION_ID_LOWER),
102      thread_id: optional(map, &keys::THREAD_ID),
103      client_request_id: optional(map, &keys::X_CLIENT_REQUEST_ID),
104      codex_window_id: optional(map, &keys::X_CODEX_WINDOW_ID),
105      codex_beta_features: optional(map, &keys::X_CODEX_BETA_FEATURES),
106      codex_turn_metadata: optional(map, &keys::X_CODEX_TURN_METADATA),
107      openai_beta: optional(map, &keys::OPENAI_BETA),
108      request_id: optional(map, &keys::X_REQUEST_ID),
109      sec_websocket_extensions: optional(map, &keys::SEC_WEBSOCKET_EXTENSIONS),
110      sec_websocket_key: optional(map, &keys::SEC_WEBSOCKET_KEY),
111      sec_websocket_version: optional(map, &keys::SEC_WEBSOCKET_VERSION),
112      cookie: optional(map, &keys::COOKIE),
113    })
114  }
115  fn dump(&self) -> HeaderMap {
116    let mut m = HeaderMap::new();
117    put(&mut m, &keys::USER_AGENT, &self.user_agent);
118    put(&mut m, &keys::AUTHORIZATION, &self.authorization);
119    put_opt(&mut m, &keys::HOST, &self.host);
120    put_opt(&mut m, &keys::ACCEPT, &self.accept);
121    put_opt(&mut m, &keys::CONNECTION, &self.connection);
122    put_opt(&mut m, &keys::UPGRADE, &self.upgrade);
123    put_opt(&mut m, &keys::ORIGINATOR, &self.originator);
124    put_opt(&mut m, &keys::CHATGPT_ACCOUNT_ID, &self.chatgpt_account_id);
125    put_opt(&mut m, &keys::VERSION, &self.version);
126    put_opt(&mut m, &keys::CONTENT_TYPE, &self.content_type);
127    put_opt(&mut m, &keys::CONTENT_LENGTH, &self.content_length);
128    put_opt(&mut m, &keys::SESSION_ID_LOWER, &self.session_id);
129    put_opt(&mut m, &keys::THREAD_ID, &self.thread_id);
130    put_opt(&mut m, &keys::X_CLIENT_REQUEST_ID, &self.client_request_id);
131    put_opt(&mut m, &keys::X_CODEX_WINDOW_ID, &self.codex_window_id);
132    put_opt(&mut m, &keys::X_CODEX_BETA_FEATURES, &self.codex_beta_features);
133    put_opt(&mut m, &keys::X_CODEX_TURN_METADATA, &self.codex_turn_metadata);
134    put_opt(&mut m, &keys::OPENAI_BETA, &self.openai_beta);
135    put_opt(&mut m, &keys::X_REQUEST_ID, &self.request_id);
136    put_opt(&mut m, &keys::SEC_WEBSOCKET_EXTENSIONS, &self.sec_websocket_extensions);
137    put_opt(&mut m, &keys::SEC_WEBSOCKET_KEY, &self.sec_websocket_key);
138    put_opt(&mut m, &keys::SEC_WEBSOCKET_VERSION, &self.sec_websocket_version);
139    put_opt(&mut m, &keys::COOKIE, &self.cookie);
140    m
141  }
142  fn known_names() -> &'static [&'static HeaderName] {
143    static NAMES: [&HeaderName; 23] = [
144      &keys::USER_AGENT,
145      &keys::AUTHORIZATION,
146      &keys::HOST,
147      &keys::ACCEPT,
148      &keys::CONNECTION,
149      &keys::UPGRADE,
150      &keys::ORIGINATOR,
151      &keys::CHATGPT_ACCOUNT_ID,
152      &keys::VERSION,
153      &keys::CONTENT_TYPE,
154      &keys::CONTENT_LENGTH,
155      &keys::SESSION_ID_LOWER,
156      &keys::THREAD_ID,
157      &keys::X_CLIENT_REQUEST_ID,
158      &keys::X_CODEX_WINDOW_ID,
159      &keys::X_CODEX_BETA_FEATURES,
160      &keys::X_CODEX_TURN_METADATA,
161      &keys::OPENAI_BETA,
162      &keys::X_REQUEST_ID,
163      &keys::SEC_WEBSOCKET_EXTENSIONS,
164      &keys::SEC_WEBSOCKET_KEY,
165      &keys::SEC_WEBSOCKET_VERSION,
166      &keys::COOKIE,
167    ];
168    &NAMES
169  }
170}
171
172impl CodexCliHeaders {
173  /// Build a [`CodexCliHeaders`] from inbound transport headers and
174  /// correlation [`TemplateVars`]. Inbound values win for transport fields;
175  /// correlation fields prefer `vars`. Missing required fields fall back to
176  /// persona-specific defaults derived from real captured traffic.
177  pub fn build(vars: &TemplateVars, inbound: &HeaderMap) -> Self {
178    Self {
179      user_agent: from_inbound_or(inbound, &keys::USER_AGENT, || {
180        "codex_exec/0.130.0 (Ubuntu 24.4.0; x86_64) unknown (codex_exec; 0.130.0)".into()
181      }),
182      authorization: from_inbound_or(inbound, &keys::AUTHORIZATION, || "<missing>".into()),
183      host: None,
184      accept: Some(from_inbound_or(inbound, &keys::ACCEPT, || "text/event-stream".into())),
185      connection: opt_from_inbound(inbound, &keys::CONNECTION),
186      upgrade: opt_from_inbound(inbound, &keys::UPGRADE),
187      originator: Some(from_inbound_or(inbound, &keys::ORIGINATOR, || "codex_exec".into())),
188      chatgpt_account_id: vars
189        .account_id
190        .clone()
191        .or_else(|| opt_from_inbound(inbound, &keys::CHATGPT_ACCOUNT_ID)),
192      version: Some(from_inbound_or(inbound, &keys::VERSION, || "0.130.0".into())),
193      content_type: opt_from_inbound(inbound, &keys::CONTENT_TYPE),
194      content_length: opt_from_inbound(inbound, &keys::CONTENT_LENGTH),
195      session_id: vars
196        .session_id
197        .clone()
198        .or_else(|| opt_from_inbound(inbound, &keys::SESSION_ID_LOWER)),
199      thread_id: opt_from_inbound(inbound, &keys::THREAD_ID),
200      client_request_id: opt_from_inbound(inbound, &keys::X_CLIENT_REQUEST_ID).or_else(|| vars.request_id.clone()),
201      codex_window_id: opt_from_inbound(inbound, &keys::X_CODEX_WINDOW_ID),
202      codex_beta_features: opt_from_inbound(inbound, &keys::X_CODEX_BETA_FEATURES),
203      codex_turn_metadata: opt_from_inbound(inbound, &keys::X_CODEX_TURN_METADATA),
204      openai_beta: opt_from_inbound(inbound, &keys::OPENAI_BETA),
205      request_id: vars
206        .request_id
207        .clone()
208        .or_else(|| opt_from_inbound(inbound, &keys::X_REQUEST_ID)),
209      sec_websocket_extensions: opt_from_inbound(inbound, &keys::SEC_WEBSOCKET_EXTENSIONS),
210      sec_websocket_key: opt_from_inbound(inbound, &keys::SEC_WEBSOCKET_KEY),
211      sec_websocket_version: opt_from_inbound(inbound, &keys::SEC_WEBSOCKET_VERSION),
212      cookie: opt_from_inbound(inbound, &keys::COOKIE),
213    }
214  }
215}
216
217#[cfg(test)]
218mod tests {
219  use super::*;
220
221  fn responses_sample() -> CodexCliHeaders {
222    CodexCliHeaders {
223      user_agent: "codex_exec/0.130.0 (Ubuntu 24.4.0; x86_64) unknown (codex_exec; 0.130.0)".into(),
224      authorization: "<redacted>".into(),
225      host: Some("chatgpt.com".into()),
226      accept: Some("text/event-stream".into()),
227      connection: None,
228      upgrade: None,
229      originator: Some("codex_exec".into()),
230      chatgpt_account_id: Some("<redacted>".into()),
231      version: Some("0.130.0".into()),
232      content_type: Some("application/json".into()),
233      content_length: Some("45273".into()),
234      session_id: Some("019e271b-4023-7081-be3e-7a69d97138a2".into()),
235      thread_id: Some("019e271b-4023-7081-be3e-7a69d97138a2".into()),
236      client_request_id: Some("019e271b-4023-7081-be3e-7a69d97138a2".into()),
237      codex_window_id: Some("019e271b-4023-7081-be3e-7a69d97138a2:0".into()),
238      codex_beta_features: Some("terminal_resize_reflow".into()),
239      codex_turn_metadata: Some("{\"session_id\":\"019e271b\"}".into()),
240      openai_beta: None,
241      request_id: None,
242      sec_websocket_extensions: None,
243      sec_websocket_key: None,
244      sec_websocket_version: None,
245      cookie: None,
246    }
247  }
248
249  #[test]
250  fn responses_round_trip() {
251    let h = responses_sample();
252    assert_eq!(CodexCliHeaders::parse(&h.dump()).unwrap(), h);
253  }
254
255  #[test]
256  fn missing_required_errors() {
257    let m = HeaderMap::new();
258    assert!(matches!(CodexCliHeaders::parse(&m), Err(Error::MissingHeader { .. })));
259  }
260
261  #[test]
262  fn build_with_empty_inbound_uses_defaults() {
263    let h = CodexCliHeaders::build(&TemplateVars::default(), &HeaderMap::new());
264    assert_eq!(
265      h.user_agent.as_str(),
266      "codex_exec/0.130.0 (Ubuntu 24.4.0; x86_64) unknown (codex_exec; 0.130.0)"
267    );
268    assert_eq!(h.authorization.as_str(), "<missing>");
269    assert!(
270      h.host.is_none(),
271      "no inbound Host => no persona-default Host (would leak to wire)"
272    );
273    assert_eq!(h.accept.as_deref(), Some("text/event-stream"));
274    assert_eq!(h.originator.as_deref(), Some("codex_exec"));
275    assert!(h.chatgpt_account_id.is_none());
276    assert_eq!(h.version.as_deref(), Some("0.130.0"));
277    assert!(h.content_type.is_none());
278    assert!(h.session_id.is_none());
279    assert!(h.thread_id.is_none());
280  }
281
282  #[test]
283  fn build_passes_through_inbound() {
284    let mut inbound = HeaderMap::new();
285    inbound.insert(&keys::USER_AGENT, "codex_exec/9.9.9");
286    inbound.insert(&keys::AUTHORIZATION, "Bearer abc");
287    inbound.insert(&keys::OPENAI_BETA, "responses=v1");
288    inbound.insert(&keys::HOST, "chatgpt.com");
289    inbound.insert(&keys::X_CLIENT_REQUEST_ID, "codex-req");
290    let h = CodexCliHeaders::build(&TemplateVars::default(), &inbound);
291    assert_eq!(h.user_agent.as_str(), "codex_exec/9.9.9");
292    assert_eq!(h.authorization.as_str(), "Bearer abc");
293    assert_eq!(h.openai_beta.as_deref(), Some("responses=v1"));
294    assert_eq!(h.host.as_deref(), None);
295    assert_eq!(h.client_request_id.as_deref(), Some("codex-req"));
296  }
297
298  #[test]
299  fn build_uses_vars_for_correlation() {
300    let vars = TemplateVars {
301      session_id: Some("ses_xyz".into()),
302      request_id: Some("req_42".into()),
303      account_id: Some("acct_z".into()),
304      ..Default::default()
305    };
306    let h = CodexCliHeaders::build(&vars, &HeaderMap::new());
307    assert_eq!(h.session_id.as_deref(), Some("ses_xyz"));
308    assert_eq!(h.client_request_id.as_deref(), Some("req_42"));
309    assert_eq!(h.request_id.as_deref(), Some("req_42"));
310    assert_eq!(h.chatgpt_account_id.as_deref(), Some("acct_z"));
311  }
312}