Skip to main content

harness_codex/
app_server.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3//! Side-effect-free capability boundary for the Codex app-server.
4//!
5//! This module describes an already-observed Codex installation. It does not
6//! discover processes, connect to app-server, read Codex configuration, or
7//! acquire authentication material. This legacy capability receipt exposes
8//! discovery and quota facts and truthfully rejects raw provider dispatch. The
9//! separate injected whole-agent transcript state machine lives in
10//! `app_server_runtime` and does not change this receipt's meaning.
11
12use router_protocol::TransportKind;
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeSet;
15use thiserror::Error;
16
17const MAX_PROBE_BYTES: usize = 16 * 1024;
18
19/// Standalone Codex CLI version verified for the current bounded receipt.
20pub const CURRENT_STANDALONE_CLI_VERSION: &str = "0.148.0";
21/// ChatGPT Desktop application version verified for the current bounded receipt.
22pub const CURRENT_DESKTOP_APP_VERSION: &str = "26.721.41059";
23/// ChatGPT Desktop build verified for the current bounded receipt.
24pub const CURRENT_DESKTOP_BUILD: u32 = 5848;
25/// Codex CLI version embedded in the verified ChatGPT Desktop application.
26pub const CURRENT_DESKTOP_CODEX_VERSION: &str = "0.147.0-alpha.6.5";
27
28/// The complete app-server RPC set SteadyMux may inspect.
29pub 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    /// A process-owned stdio child; observing it does not make it attachable.
107    OwnedStdio,
108    /// A supported, explicitly managed Unix-socket daemon.
109    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/// Non-secret observations supplied by a separately controlled probe.
144#[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    /// Parses only a bounded, non-secret capability observation.
157    ///
158    /// Sensitive field markers are rejected before JSON deserialization. Parse
159    /// failures deliberately discard serde's payload-derived diagnostic.
160    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    /// Deterministic observation of the versions reviewed for PRD-001f/M5.
175    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}