1use router_protocol::TransportKind;
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeSet;
15use thiserror::Error;
16
17const MAX_PROBE_BYTES: usize = 16 * 1024;
18
19pub const CURRENT_STANDALONE_CLI_VERSION: &str = "0.148.0";
21pub const CURRENT_DESKTOP_APP_VERSION: &str = "26.721.41059";
23pub const CURRENT_DESKTOP_BUILD: u32 = 5848;
25pub const CURRENT_DESKTOP_CODEX_VERSION: &str = "0.147.0-alpha.6.5";
27
28pub const SUPPORTED_DISCOVERY_QUOTA_RPCS: [AppServerRpc; 4] = [
30 AppServerRpc::AccountRead,
31 AppServerRpc::AccountRateLimitsRead,
32 AppServerRpc::ModelList,
33 AppServerRpc::ModelProviderCapabilitiesRead,
34];
35
36const FORBIDDEN_FIELD_NAMES: [&[u8]; 9] = [
37 b"access_token",
38 b"accesstoken",
39 b"refresh_token",
40 b"refreshtoken",
41 b"id_token",
42 b"idtoken",
43 b"authorization",
44 b"api_key",
45 b"apikey",
46];
47
48const FORBIDDEN_INPUT_MARKERS: [&[u8]; 5] = [
49 b"codex_access_token",
50 b"openai_api_key",
51 b"requires_openai_auth",
52 b"auth.json",
53 b"keychain",
54];
55
56#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
57#[serde(transparent)]
58pub struct CodexCliVersion(String);
59
60impl CodexCliVersion {
61 pub fn parse(value: impl Into<String>) -> Result<Self, CapabilityInputError> {
62 let value = value.into();
63 if value.is_empty()
64 || value.len() > 64
65 || !value
66 .bytes()
67 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+'))
68 {
69 return Err(CapabilityInputError::InvalidInput);
70 }
71 Ok(Self(value))
72 }
73
74 pub fn as_str(&self) -> &str {
75 &self.0
76 }
77}
78
79impl<'de> Deserialize<'de> for CodexCliVersion {
80 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
81 where
82 D: serde::Deserializer<'de>,
83 {
84 let value = String::deserialize(deserializer)?;
85 Self::parse(value).map_err(serde::de::Error::custom)
86 }
87}
88
89#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct InstalledCodexCli {
92 pub version: CodexCliVersion,
93}
94
95#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct InstalledCodexDesktop {
98 pub app_version: CodexCliVersion,
99 pub build: u32,
100 pub embedded_codex_version: CodexCliVersion,
101}
102
103#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum AppServerTransport {
106 OwnedStdio,
108 ManagedUnixSocket,
110}
111
112#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum AppServerAuthMode {
115 ChatgptManaged,
116 ApiKey,
117 ExternalTokens,
118}
119
120#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
121pub enum AppServerRpc {
122 #[serde(rename = "account/read")]
123 AccountRead,
124 #[serde(rename = "account/rateLimits/read")]
125 AccountRateLimitsRead,
126 #[serde(rename = "model/list")]
127 ModelList,
128 #[serde(rename = "modelProvider/capabilities/read")]
129 ModelProviderCapabilitiesRead,
130}
131
132impl AppServerRpc {
133 pub const fn as_str(self) -> &'static str {
134 match self {
135 Self::AccountRead => "account/read",
136 Self::AccountRateLimitsRead => "account/rateLimits/read",
137 Self::ModelList => "model/list",
138 Self::ModelProviderCapabilitiesRead => "modelProvider/capabilities/read",
139 }
140 }
141}
142
143#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct CodexAppServerProbe {
147 pub standalone_cli: InstalledCodexCli,
148 pub desktop: InstalledCodexDesktop,
149 pub transport: AppServerTransport,
150 pub auth_mode: AppServerAuthMode,
151 pub managed_daemon_available: bool,
152 pub advertised_rpcs: BTreeSet<AppServerRpc>,
153}
154
155impl CodexAppServerProbe {
156 pub fn from_json(input: &[u8]) -> Result<Self, CapabilityInputError> {
161 if input.is_empty() || input.len() > MAX_PROBE_BYTES {
162 return Err(CapabilityInputError::InvalidInput);
163 }
164 if contains_forbidden_json_key(input)
165 || FORBIDDEN_INPUT_MARKERS
166 .iter()
167 .any(|marker| contains_ascii_case_insensitive(input, marker))
168 {
169 return Err(CapabilityInputError::SensitiveInputRejected);
170 }
171 serde_json::from_slice(input).map_err(|_| CapabilityInputError::InvalidInput)
172 }
173
174 pub fn current_observed() -> Self {
176 Self {
177 standalone_cli: InstalledCodexCli {
178 version: CodexCliVersion(CURRENT_STANDALONE_CLI_VERSION.to_owned()),
179 },
180 desktop: InstalledCodexDesktop {
181 app_version: CodexCliVersion(CURRENT_DESKTOP_APP_VERSION.to_owned()),
182 build: CURRENT_DESKTOP_BUILD,
183 embedded_codex_version: CodexCliVersion(CURRENT_DESKTOP_CODEX_VERSION.to_owned()),
184 },
185 transport: AppServerTransport::OwnedStdio,
186 auth_mode: AppServerAuthMode::ChatgptManaged,
187 managed_daemon_available: false,
188 advertised_rpcs: SUPPORTED_DISCOVERY_QUOTA_RPCS.into_iter().collect(),
189 }
190 }
191
192 pub fn capability_receipt(&self) -> CodexAppServerCapabilityReceipt {
193 let mut unavailable_reasons = vec![
194 CodexSubscriptionUnavailableReason::NoRawInferenceRpc,
195 CodexSubscriptionUnavailableReason::DesktopExternalAttachmentUnsupported,
196 ];
197 if !self.matches_current_versions()
198 || self.advertised_rpcs != SUPPORTED_DISCOVERY_QUOTA_RPCS.into_iter().collect()
199 {
200 unavailable_reasons.push(CodexSubscriptionUnavailableReason::ProtocolMismatch);
201 }
202 if !self.managed_daemon_available {
203 unavailable_reasons.push(CodexSubscriptionUnavailableReason::MissingManagedDaemon);
204 }
205 if self.auth_mode != AppServerAuthMode::ChatgptManaged {
206 unavailable_reasons.push(CodexSubscriptionUnavailableReason::UnsupportedAuthMode);
207 }
208
209 CodexAppServerCapabilityReceipt {
210 standalone_cli: self.standalone_cli.clone(),
211 desktop: self.desktop.clone(),
212 transport: self.transport,
213 auth_mode: self.auth_mode,
214 supported_discovery_quota_rpcs: self.advertised_rpcs.clone(),
215 openai_transport: TransportKind::HarnessManaged,
216 raw_inference_dispatch: false,
217 desktop_external_attach: false,
218 unavailable_reasons,
219 }
220 }
221
222 fn matches_current_versions(&self) -> bool {
223 self.standalone_cli.version.as_str() == CURRENT_STANDALONE_CLI_VERSION
224 && self.desktop.app_version.as_str() == CURRENT_DESKTOP_APP_VERSION
225 && self.desktop.build == CURRENT_DESKTOP_BUILD
226 && self.desktop.embedded_codex_version.as_str() == CURRENT_DESKTOP_CODEX_VERSION
227 }
228}
229
230#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(rename_all = "snake_case")]
232pub enum CodexSubscriptionUnavailableReason {
233 NoRawInferenceRpc,
234 DesktopExternalAttachmentUnsupported,
235 ProtocolMismatch,
236 MissingManagedDaemon,
237 UnsupportedAuthMode,
238}
239
240#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
241pub struct CodexAppServerCapabilityReceipt {
242 pub standalone_cli: InstalledCodexCli,
243 pub desktop: InstalledCodexDesktop,
244 pub transport: AppServerTransport,
245 pub auth_mode: AppServerAuthMode,
246 pub supported_discovery_quota_rpcs: BTreeSet<AppServerRpc>,
247 openai_transport: TransportKind,
248 raw_inference_dispatch: bool,
249 desktop_external_attach: bool,
250 pub unavailable_reasons: Vec<CodexSubscriptionUnavailableReason>,
251}
252
253impl CodexAppServerCapabilityReceipt {
254 pub const fn openai_transport(&self) -> TransportKind {
255 self.openai_transport
256 }
257
258 pub const fn raw_inference_dispatch(&self) -> bool {
259 self.raw_inference_dispatch
260 }
261
262 pub const fn desktop_external_attach(&self) -> bool {
263 self.desktop_external_attach
264 }
265
266 pub const fn dispatch_available(&self) -> bool {
267 false
268 }
269}
270
271#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)]
272pub enum CapabilityInputError {
273 #[error("the capability observation is invalid")]
274 InvalidInput,
275 #[error("the capability observation contained forbidden sensitive material")]
276 SensitiveInputRejected,
277}
278
279fn contains_ascii_case_insensitive(haystack: &[u8], needle: &[u8]) -> bool {
280 haystack.windows(needle.len()).any(|window| {
281 window
282 .iter()
283 .zip(needle)
284 .all(|(left, right)| left.eq_ignore_ascii_case(right))
285 })
286}
287
288fn contains_forbidden_json_key(input: &[u8]) -> bool {
289 let mut cursor = 0;
290 while cursor < input.len() {
291 if input[cursor] != b'"' {
292 cursor += 1;
293 continue;
294 }
295 let key_start = cursor + 1;
296 let mut key_end = key_start;
297 let mut escaped = false;
298 while key_end < input.len() {
299 match input[key_end] {
300 b'\\' => {
301 escaped = true;
302 key_end = key_end.saturating_add(2);
303 }
304 b'"' => break,
305 _ => key_end += 1,
306 }
307 }
308 if key_end == input.len() {
309 return false;
310 }
311 let mut after = key_end + 1;
312 while after < input.len() && input[after].is_ascii_whitespace() {
313 after += 1;
314 }
315 if !escaped
316 && input.get(after) == Some(&b':')
317 && FORBIDDEN_FIELD_NAMES
318 .iter()
319 .any(|name| contains_ascii_case_insensitive(&input[key_start..key_end], name))
320 {
321 return true;
322 }
323 cursor = key_end + 1;
324 }
325 false
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use serde_json::json;
332
333 fn current_probe_json() -> serde_json::Value {
334 json!({
335 "standalone_cli": { "version": CURRENT_STANDALONE_CLI_VERSION },
336 "desktop": {
337 "app_version": CURRENT_DESKTOP_APP_VERSION,
338 "build": CURRENT_DESKTOP_BUILD,
339 "embedded_codex_version": CURRENT_DESKTOP_CODEX_VERSION
340 },
341 "transport": "owned_stdio",
342 "auth_mode": "chatgpt_managed",
343 "managed_daemon_available": false,
344 "advertised_rpcs": [
345 "account/read",
346 "account/rateLimits/read",
347 "model/list",
348 "modelProvider/capabilities/read"
349 ]
350 })
351 }
352
353 fn parse(value: &serde_json::Value) -> Result<CodexAppServerProbe, CapabilityInputError> {
354 CodexAppServerProbe::from_json(
355 &serde_json::to_vec(value).expect("serialize capability fixture"),
356 )
357 }
358
359 #[test]
360 fn discovery_and_quota_rpc_allowlist_is_exact() {
361 assert_eq!(
362 SUPPORTED_DISCOVERY_QUOTA_RPCS.map(AppServerRpc::as_str),
363 [
364 "account/read",
365 "account/rateLimits/read",
366 "model/list",
367 "modelProvider/capabilities/read"
368 ]
369 );
370 let mut unsupported = current_probe_json();
371 unsupported["advertised_rpcs"] = json!(["turn/start"]);
372 assert_eq!(parse(&unsupported), Err(CapabilityInputError::InvalidInput));
373 }
374
375 #[test]
376 fn unknown_and_token_shaped_fields_are_rejected_without_echo() {
377 let mut unknown = current_probe_json();
378 unknown["future_contract"] = json!(true);
379 assert_eq!(parse(&unknown), Err(CapabilityInputError::InvalidInput));
380
381 let canary = "secret-capability-canary";
382 for field in ["access_token", "refreshToken", "authorization", "api_key"] {
383 let mut sensitive = current_probe_json();
384 sensitive[field] = json!(canary);
385 let error = parse(&sensitive).expect_err("sensitive input must be rejected");
386 assert_eq!(error, CapabilityInputError::SensitiveInputRejected);
387 assert!(!format!("{error:?} {error}").contains(canary));
388 }
389 }
390
391 #[test]
392 fn forbidden_auth_sources_are_rejected_even_when_nested_in_values() {
393 for canary in [
394 "CODEX_ACCESS_TOKEN",
395 "OPENAI_API_KEY",
396 "requires_openai_auth",
397 "/tmp/.codex/auth.json",
398 ] {
399 let payload = format!("{{\"future_contract\":\"{canary}\"}}");
400 assert_eq!(
401 CodexAppServerProbe::from_json(payload.as_bytes()),
402 Err(CapabilityInputError::SensitiveInputRejected)
403 );
404 }
405 }
406
407 #[test]
408 fn version_mismatch_is_explicit_and_remains_fail_closed() {
409 let mut mismatched = current_probe_json();
410 mismatched["standalone_cli"]["version"] = json!("0.999.0");
411 let receipt = parse(&mismatched)
412 .expect("valid mismatch fixture")
413 .capability_receipt();
414 assert!(
415 receipt
416 .unavailable_reasons
417 .contains(&CodexSubscriptionUnavailableReason::ProtocolMismatch)
418 );
419 assert!(!receipt.dispatch_available());
420 }
421
422 #[test]
423 fn missing_managed_daemon_is_explicit() {
424 let receipt = CodexAppServerProbe::current_observed().capability_receipt();
425 assert!(
426 receipt
427 .unavailable_reasons
428 .contains(&CodexSubscriptionUnavailableReason::MissingManagedDaemon)
429 );
430 }
431
432 #[test]
433 fn unsupported_auth_mode_is_explicit() {
434 let mut api_key = current_probe_json();
435 api_key["auth_mode"] = json!("api_key");
436 let receipt = parse(&api_key)
437 .expect("valid unsupported auth fixture")
438 .capability_receipt();
439 assert!(
440 receipt
441 .unavailable_reasons
442 .contains(&CodexSubscriptionUnavailableReason::UnsupportedAuthMode)
443 );
444 assert!(!receipt.dispatch_available());
445 }
446
447 #[test]
448 fn current_receipt_is_harness_managed_and_cannot_dispatch() {
449 let receipt = CodexAppServerProbe::current_observed().capability_receipt();
450 assert_eq!(receipt.openai_transport(), TransportKind::HarnessManaged);
451 assert!(!receipt.raw_inference_dispatch());
452 assert!(!receipt.desktop_external_attach());
453 assert!(!receipt.dispatch_available());
454 assert_eq!(
455 receipt.unavailable_reasons,
456 vec![
457 CodexSubscriptionUnavailableReason::NoRawInferenceRpc,
458 CodexSubscriptionUnavailableReason::DesktopExternalAttachmentUnsupported,
459 CodexSubscriptionUnavailableReason::MissingManagedDaemon,
460 ]
461 );
462 assert_eq!(
463 receipt.supported_discovery_quota_rpcs,
464 SUPPORTED_DISCOVERY_QUOTA_RPCS.into_iter().collect()
465 );
466 let encoded = serde_json::to_value(&receipt).expect("serialize capability receipt");
467 assert_eq!(encoded["openai_transport"], "harness_managed");
468 assert_eq!(encoded["raw_inference_dispatch"], false);
469 assert_eq!(encoded["desktop_external_attach"], false);
470 }
471}