Skip to main content

harness_codex/
app_server_process.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3//! Owned child-process transport for the Codex whole-agent app-server seam.
4//!
5//! The transport starts a new process and communicates only through its piped
6//! stdio. It never discovers or attaches to an existing app-server or Desktop
7//! process and never reads authentication files, keychains, or token values.
8
9use crate::{
10    CURRENT_DESKTOP_CODEX_VERSION, CURRENT_STANDALONE_CLI_VERSION, CodexAgentRuntime,
11    CodexToolPolicy, ManagedAccountProjection, ModelPageProjection, RateLimitsProjection,
12    ReasoningEffort, RuntimeError, RuntimeEvent, TerminalStatus, TurnCompletionReceipt,
13};
14use rustix::fs::{AtFlags, FileType, Mode, OFlags, Stat, fstat, open, openat, statat};
15use serde::Serialize;
16use sha2::Digest as _;
17use std::collections::VecDeque;
18use std::ffi::{OsStr, OsString};
19use std::fmt;
20use std::fs;
21use std::io::{BufRead, BufReader, Read, Write};
22#[cfg(unix)]
23use std::os::unix::fs::MetadataExt as _;
24use std::path::{Component, Path, PathBuf};
25use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio};
26use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender};
27use std::thread::{self, JoinHandle};
28use std::time::{Duration, Instant};
29use thiserror::Error;
30
31const DEFAULT_MAX_LINE_BYTES: usize = 256 * 1024;
32const FRAME_CHANNEL_CAPACITY: usize = 64;
33const MAX_BUFFERED_EVENTS: usize = 256;
34const MAX_IGNORED_NON_SEMANTIC_NOTIFICATIONS: usize = 64;
35const MAX_DISCOVERY_PAGES: usize = 8;
36const MAX_MCP_SERVER_CONFIG_BYTES: usize = 1024 * 1024;
37const MAX_MCP_SERVER_KEYS: usize = 32;
38const MAX_MCP_SERVER_KEY_BYTES: usize = 64;
39const FIXED_PRODUCTION_ARG_COUNT: usize = 19;
40const MAX_PROCESS_ARGS: usize = FIXED_PRODUCTION_ARG_COUNT + (MAX_MCP_SERVER_KEYS * 2);
41const MAX_ARG_BYTES: usize = 4096;
42const MIN_TIMEOUT: Duration = Duration::from_millis(10);
43const MAX_TIMEOUT: Duration = Duration::from_secs(120);
44pub const APP_SERVER_DISCOVERY_SCHEMA_VERSION: &str = "1.0.0";
45pub const CODEX_APP_SERVER_PROTOCOL_VERSION: &str = "2026-07-29.app-server-jsonl";
46const MANAGED_CHILD_ENV_ALLOWLIST: [&str; 9] = [
47    "HOME",
48    "CODEX_HOME",
49    "PATH",
50    "TMPDIR",
51    "LANG",
52    "LC_ALL",
53    "LC_CTYPE",
54    "SSL_CERT_FILE",
55    "SSL_CERT_DIR",
56];
57
58/// Fixed, content-free reasons for a fail-closed owned-child configuration
59/// boundary.  These are intentionally classes rather than filesystem or
60/// configuration details so live evidence can locate the boundary without
61/// projecting paths, TOML keys, values, or digests.
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub enum PreflightConfigurationRefusal {
64    CodexHomeStructural,
65    ConfigOpenTypeOwnerModeLink,
66    ConfigFileIdentity,
67    ConfigContentDigest,
68    ConfigSemanticKeys,
69    ConfigOverrideDigest,
70    ConfigArgCap,
71    Other,
72}
73
74impl PreflightConfigurationRefusal {
75    const fn as_str(self) -> &'static str {
76        match self {
77            Self::CodexHomeStructural => "codex-home-structural",
78            Self::ConfigOpenTypeOwnerModeLink => "config-open-type-owner-mode-link",
79            Self::ConfigFileIdentity => "config-file-identity",
80            Self::ConfigContentDigest => "config-content-digest",
81            Self::ConfigSemanticKeys => "config-semantic-keys",
82            Self::ConfigOverrideDigest => "config-override-digest",
83            Self::ConfigArgCap => "config-arg-cap",
84            Self::Other => "config-other",
85        }
86    }
87}
88
89/// Content-free evidence that the owned child disables every structurally
90/// observed Codex MCP server. Server names are intentionally not exposed.
91#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
92pub struct OwnedChildMcpIsolationAttestation {
93    pub configured_server_count: usize,
94    pub config_sha256: String,
95    pub override_set_sha256: String,
96}
97
98/// A bounded line transport consumed by [`CodexAppServerClient`].
99pub trait AppServerLineTransport {
100    fn send_line(&mut self, line: &str) -> Result<(), AppServerProcessError>;
101    fn recv_line(&mut self, timeout: Duration) -> Result<Vec<u8>, AppServerProcessError>;
102    fn shutdown(&mut self) -> Result<ShutdownReceipt, AppServerProcessError>;
103}
104
105/// Configuration for a newly owned `codex app-server` process.
106pub struct OwnedAppServerConfig {
107    program: PathBuf,
108    executable_identity: ExecutableIdentity,
109    expected_executable_sha256: Option<String>,
110    args: Vec<OsString>,
111    required_app_server_version: Option<String>,
112    operation_timeout: Duration,
113    shutdown_timeout: Duration,
114    max_line_bytes: usize,
115    mcp_isolation: Option<McpServerIsolation>,
116}
117
118struct McpServerIsolation {
119    codex_home: PathBuf,
120    codex_home_directory: fs::File,
121    // `CODEX_HOME` is a private directory whose metadata legitimately changes
122    // when a separately authorized zero-turn qualification writes unrelated
123    // state.  Keep its structural identity pinned, but reserve full byte and
124    // timestamp pinning for the security-relevant `config.toml` below.
125    codex_home_identity: PinnedDirectoryIdentity,
126    config_identity: PinnedFilesystemIdentity,
127    config_sha256: String,
128    configured_server_count: usize,
129    server_keys: Vec<String>,
130    override_set_sha256: String,
131}
132
133#[derive(Clone, Debug, PartialEq, Eq)]
134struct PinnedFilesystemIdentity {
135    device: u64,
136    inode: u64,
137    owner_uid: u32,
138    mode: u32,
139    nlink: u64,
140    size: u64,
141    modified_seconds: i64,
142    modified_nanoseconds: i64,
143}
144
145/// The immutable security boundary for the `CODEX_HOME` directory itself.
146///
147/// Directory size and modification time change when an owned qualification
148/// writes an unrelated, owner-private state file.  They are therefore not
149/// admission facts for the later owned-child spawn.  The directory descriptor,
150/// canonical path, ownership, type/mode, device, and inode remain
151/// pinned; `config.toml` retains the full identity and byte-digest binding.
152#[derive(Clone, Debug, PartialEq, Eq)]
153struct PinnedDirectoryIdentity {
154    device: u64,
155    inode: u64,
156    owner_uid: u32,
157    mode: u32,
158}
159
160#[derive(Clone, Debug, PartialEq, Eq)]
161struct ExecutableIdentity {
162    sha256: String,
163    #[cfg(unix)]
164    device: u64,
165    #[cfg(unix)]
166    inode: u64,
167    #[cfg(unix)]
168    owner_uid: u32,
169    #[cfg(unix)]
170    mode: u32,
171    #[cfg(unix)]
172    nlink: u64,
173    #[cfg(unix)]
174    size: u64,
175    #[cfg(unix)]
176    modified_seconds: i64,
177    #[cfg(unix)]
178    modified_nanoseconds: i64,
179}
180
181impl fmt::Debug for OwnedAppServerConfig {
182    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
183        formatter
184            .debug_struct("OwnedAppServerConfig")
185            .field("program", &self.program)
186            .field("args", &"[REDACTED]")
187            .field("operation_timeout", &self.operation_timeout)
188            .field("shutdown_timeout", &self.shutdown_timeout)
189            .field("max_line_bytes", &self.max_line_bytes)
190            .finish()
191    }
192}
193
194impl OwnedAppServerConfig {
195    /// Creates the production owned-child command. Authentication remains under
196    /// the child Codex process's normal managed credential lifecycle. The
197    /// child always resolves the managed ChatGPT account against the OpenAI
198    /// provider so Phase-1 discovery is deterministic even when the user's
199    /// `~/.codex/config.toml` defaults `model_provider` to the continuity
200    /// router (an OpenAI-compatible local provider that has no ChatGPT
201    /// account to report from `account/read`).
202    pub fn codex(
203        executable: impl Into<PathBuf>,
204        operation_timeout: Duration,
205        shutdown_timeout: Duration,
206    ) -> Result<Self, AppServerProcessError> {
207        let codex_home = codex_home_from_environment()?;
208        Self::codex_with_mcp_server_isolation(
209            executable,
210            codex_home,
211            operation_timeout,
212            shutdown_timeout,
213        )
214    }
215
216    /// Creates the production owned-child command while pinning the exact
217    /// Codex configuration directory from which MCP server names are read.
218    /// This is public so platform callers can bind the same managed
219    /// `CODEX_HOME` directory they pass to the child, without reading any
220    /// command, environment, or credential value from its configuration.
221    pub fn codex_with_mcp_server_isolation(
222        executable: impl Into<PathBuf>,
223        codex_home: impl Into<PathBuf>,
224        operation_timeout: Duration,
225        shutdown_timeout: Duration,
226    ) -> Result<Self, AppServerProcessError> {
227        let isolation = McpServerIsolation::read(codex_home.into())?;
228        let mut args = vec![
229            OsString::from("app-server"),
230            OsString::from("--listen"),
231            OsString::from("stdio://"),
232            OsString::from("-c"),
233            OsString::from("model_provider=\"openai\""),
234            OsString::from("-c"),
235            OsString::from("web_search=\"disabled\""),
236            OsString::from("--disable"),
237            OsString::from("hooks"),
238            OsString::from("--disable"),
239            OsString::from("apps"),
240            OsString::from("--disable"),
241            OsString::from("multi_agent"),
242            OsString::from("--disable"),
243            OsString::from("shell_tool"),
244            OsString::from("--disable"),
245            OsString::from("unified_exec"),
246            OsString::from("--disable"),
247            OsString::from("standalone_web_search"),
248        ];
249        args.extend(isolation.server_keys.iter().flat_map(|key| {
250            [
251                OsString::from("-c"),
252                OsString::from(format!("mcp_servers.{key}.enabled=false")),
253            ]
254        }));
255        let mut config = Self::new(
256            executable.into(),
257            args,
258            operation_timeout,
259            shutdown_timeout,
260            DEFAULT_MAX_LINE_BYTES,
261            Some(isolation),
262        )?;
263        config.required_app_server_version = Some(CURRENT_STANDALONE_CLI_VERSION.to_owned());
264        Ok(config)
265    }
266
267    /// Creates an owned Codex child with the caller-provided executable digest
268    /// as an explicit boundary. The digest is checked at construction and
269    /// again immediately before process creation.
270    pub fn codex_pinned(
271        executable: impl Into<PathBuf>,
272        expected_executable_sha256: &str,
273        operation_timeout: Duration,
274        shutdown_timeout: Duration,
275    ) -> Result<Self, AppServerProcessError> {
276        let mut config = Self::codex(executable, operation_timeout, shutdown_timeout)?;
277        config.require_expected_executable_sha256(expected_executable_sha256)?;
278        Ok(config)
279    }
280
281    /// Pinned variant of [`Self::codex_with_mcp_server_isolation`].
282    pub fn codex_pinned_with_mcp_server_isolation(
283        executable: impl Into<PathBuf>,
284        expected_executable_sha256: &str,
285        codex_home: impl Into<PathBuf>,
286        operation_timeout: Duration,
287        shutdown_timeout: Duration,
288    ) -> Result<Self, AppServerProcessError> {
289        let mut config = Self::codex_with_mcp_server_isolation(
290            executable,
291            codex_home,
292            operation_timeout,
293            shutdown_timeout,
294        )?;
295        config.require_expected_executable_sha256(expected_executable_sha256)?;
296        Ok(config)
297    }
298
299    /// Creates the bounded owned-child command for the separately qualified
300    /// ChatGPT Desktop app-server executable. The process remains a fresh
301    /// owned child; this never attaches to a running Desktop process.
302    pub fn codex_desktop(
303        executable: impl Into<PathBuf>,
304        operation_timeout: Duration,
305        shutdown_timeout: Duration,
306    ) -> Result<Self, AppServerProcessError> {
307        let mut config = Self::codex(executable, operation_timeout, shutdown_timeout)?;
308        config.required_app_server_version = Some(CURRENT_DESKTOP_CODEX_VERSION.to_owned());
309        Ok(config)
310    }
311
312    /// Desktop variant of [`Self::codex_pinned`], retaining the separately
313    /// reviewed Desktop app-server version boundary.
314    pub fn codex_desktop_pinned(
315        executable: impl Into<PathBuf>,
316        expected_executable_sha256: &str,
317        operation_timeout: Duration,
318        shutdown_timeout: Duration,
319    ) -> Result<Self, AppServerProcessError> {
320        let mut config = Self::codex_desktop(executable, operation_timeout, shutdown_timeout)?;
321        config.require_expected_executable_sha256(expected_executable_sha256)?;
322        Ok(config)
323    }
324
325    /// Desktop variant of [`Self::codex_pinned_with_mcp_server_isolation`],
326    /// retaining the separately reviewed Desktop app-server version boundary.
327    pub fn codex_desktop_pinned_with_mcp_server_isolation(
328        executable: impl Into<PathBuf>,
329        expected_executable_sha256: &str,
330        codex_home: impl Into<PathBuf>,
331        operation_timeout: Duration,
332        shutdown_timeout: Duration,
333    ) -> Result<Self, AppServerProcessError> {
334        let mut config = Self::codex_with_mcp_server_isolation(
335            executable,
336            codex_home,
337            operation_timeout,
338            shutdown_timeout,
339        )?;
340        config.required_app_server_version = Some(CURRENT_DESKTOP_CODEX_VERSION.to_owned());
341        config.require_expected_executable_sha256(expected_executable_sha256)?;
342        Ok(config)
343    }
344
345    fn new(
346        program: PathBuf,
347        args: Vec<OsString>,
348        operation_timeout: Duration,
349        shutdown_timeout: Duration,
350        max_line_bytes: usize,
351        mcp_isolation: Option<McpServerIsolation>,
352    ) -> Result<Self, AppServerProcessError> {
353        if !program.is_absolute()
354            || args.len() > MAX_PROCESS_ARGS
355            || args.iter().any(|arg| !valid_arg(arg))
356            || !valid_timeout(operation_timeout)
357            || !valid_timeout(shutdown_timeout)
358            || !(1024..=DEFAULT_MAX_LINE_BYTES).contains(&max_line_bytes)
359        {
360            return Err(AppServerProcessError::InvalidConfiguration);
361        }
362        let program = program
363            .canonicalize()
364            .map_err(|_| AppServerProcessError::InvalidConfiguration)?;
365        let executable_identity = capture_executable_identity(&program)?;
366        Ok(Self {
367            program,
368            executable_identity,
369            expected_executable_sha256: None,
370            args,
371            required_app_server_version: None,
372            operation_timeout,
373            shutdown_timeout,
374            max_line_bytes,
375            mcp_isolation,
376        })
377    }
378
379    #[cfg(test)]
380    fn fixture(program: &std::path::Path, args: Vec<OsString>, timeout: Duration) -> Self {
381        Self::new(
382            program.to_path_buf(),
383            args,
384            timeout,
385            Duration::from_millis(250),
386            DEFAULT_MAX_LINE_BYTES,
387            None,
388        )
389        .expect("valid fixture child configuration")
390    }
391
392    pub const fn operation_timeout(&self) -> Duration {
393        self.operation_timeout
394    }
395
396    /// The exact standalone CLI protocol version reviewed for this adapter.
397    /// Callers must reject a different installed version before live use.
398    pub const fn compatible_cli_version(&self) -> &'static str {
399        CURRENT_STANDALONE_CLI_VERSION
400    }
401
402    fn require_expected_executable_sha256(
403        &mut self,
404        expected_executable_sha256: &str,
405    ) -> Result<(), AppServerProcessError> {
406        if !valid_sha256(expected_executable_sha256)
407            || self.executable_identity.sha256 != expected_executable_sha256
408        {
409            return Err(AppServerProcessError::InvalidConfiguration);
410        }
411        self.expected_executable_sha256 = Some(expected_executable_sha256.to_owned());
412        Ok(())
413    }
414
415    fn revalidate_before_spawn(&self) -> Result<(), AppServerProcessError> {
416        let actual = capture_executable_identity(&self.program)?;
417        if actual != self.executable_identity
418            || self
419                .expected_executable_sha256
420                .as_deref()
421                .is_some_and(|expected| actual.sha256 != expected)
422        {
423            return Err(AppServerProcessError::InvalidConfiguration);
424        }
425        if let Some(isolation) = &self.mcp_isolation {
426            isolation.revalidate()?;
427        }
428        Ok(())
429    }
430
431    /// Returns only sealed MCP isolation facts. Names and configuration values
432    /// never cross this boundary.
433    pub fn mcp_isolation_attestation(&self) -> Option<OwnedChildMcpIsolationAttestation> {
434        self.mcp_isolation
435            .as_ref()
436            .map(|isolation| isolation.attestation())
437    }
438
439    fn managed_codex_home(&self) -> Option<&Path> {
440        self.mcp_isolation
441            .as_ref()
442            .map(|isolation| isolation.codex_home.as_path())
443    }
444}
445
446impl McpServerIsolation {
447    fn read(codex_home: PathBuf) -> Result<Self, AppServerProcessError> {
448        let codex_home_directory = open_owned_directory(&codex_home).map_err(|_| {
449            configuration_refusal(PreflightConfigurationRefusal::CodexHomeStructural)
450        })?;
451        let codex_home_stat = fstat(&codex_home_directory).map_err(|_| {
452            configuration_refusal(PreflightConfigurationRefusal::CodexHomeStructural)
453        })?;
454        validate_owned_directory(&codex_home_stat).map_err(|_| {
455            configuration_refusal(PreflightConfigurationRefusal::CodexHomeStructural)
456        })?;
457        let codex_home_identity = pinned_directory_identity(&codex_home_stat).map_err(|_| {
458            configuration_refusal(PreflightConfigurationRefusal::CodexHomeStructural)
459        })?;
460
461        let config = fs::File::from(
462            openat(
463                &codex_home_directory,
464                "config.toml",
465                OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
466                Mode::empty(),
467            )
468            .map_err(|_| {
469                configuration_refusal(PreflightConfigurationRefusal::ConfigOpenTypeOwnerModeLink)
470            })?,
471        );
472        let before = fstat(&config).map_err(|_| {
473            configuration_refusal(PreflightConfigurationRefusal::ConfigOpenTypeOwnerModeLink)
474        })?;
475        let config_identity = pinned_identity(&before).map_err(|_| {
476            configuration_refusal(PreflightConfigurationRefusal::ConfigOpenTypeOwnerModeLink)
477        })?;
478        validate_owned_regular_file(&before, Some(MAX_MCP_SERVER_CONFIG_BYTES)).map_err(|_| {
479            configuration_refusal(PreflightConfigurationRefusal::ConfigOpenTypeOwnerModeLink)
480        })?;
481        let named_before = statat(
482            &codex_home_directory,
483            "config.toml",
484            AtFlags::SYMLINK_NOFOLLOW,
485        )
486        .map_err(|_| configuration_refusal(PreflightConfigurationRefusal::ConfigFileIdentity))?;
487        if pinned_identity(&named_before)
488            .map_err(|_| configuration_refusal(PreflightConfigurationRefusal::ConfigFileIdentity))?
489            != config_identity
490        {
491            return Err(configuration_refusal(
492                PreflightConfigurationRefusal::ConfigFileIdentity,
493            ));
494        }
495
496        let mut bytes = Vec::with_capacity(usize::try_from(before.st_size).unwrap_or(0));
497        (&config)
498            .take((MAX_MCP_SERVER_CONFIG_BYTES + 1) as u64)
499            .read_to_end(&mut bytes)
500            .map_err(|_| {
501                configuration_refusal(PreflightConfigurationRefusal::ConfigFileIdentity)
502            })?;
503        let after = fstat(&config).map_err(|_| {
504            configuration_refusal(PreflightConfigurationRefusal::ConfigFileIdentity)
505        })?;
506        let named_after = statat(
507            &codex_home_directory,
508            "config.toml",
509            AtFlags::SYMLINK_NOFOLLOW,
510        )
511        .map_err(|_| configuration_refusal(PreflightConfigurationRefusal::ConfigFileIdentity))?;
512        if bytes.len() > MAX_MCP_SERVER_CONFIG_BYTES
513            || pinned_identity(&after).map_err(|_| {
514                configuration_refusal(PreflightConfigurationRefusal::ConfigFileIdentity)
515            })? != config_identity
516            || pinned_identity(&named_after).map_err(|_| {
517                configuration_refusal(PreflightConfigurationRefusal::ConfigFileIdentity)
518            })? != config_identity
519        {
520            return Err(configuration_refusal(
521                PreflightConfigurationRefusal::ConfigFileIdentity,
522            ));
523        }
524
525        let server_keys = parse_mcp_server_keys(&bytes)?;
526        let configured_server_count = server_keys.len();
527        let config_sha256 = sha256_bytes(&bytes);
528        let override_set_sha256 = mcp_override_set_sha256(&server_keys);
529        Ok(Self {
530            codex_home,
531            codex_home_directory,
532            codex_home_identity,
533            config_identity,
534            config_sha256,
535            configured_server_count,
536            server_keys,
537            override_set_sha256,
538        })
539    }
540
541    fn revalidate(&self) -> Result<(), AppServerProcessError> {
542        let directory = fstat(&self.codex_home_directory).map_err(|_| {
543            configuration_refusal(PreflightConfigurationRefusal::CodexHomeStructural)
544        })?;
545        validate_owned_directory(&directory).map_err(|_| {
546            configuration_refusal(PreflightConfigurationRefusal::CodexHomeStructural)
547        })?;
548        if pinned_directory_identity(&directory).map_err(|_| {
549            configuration_refusal(PreflightConfigurationRefusal::CodexHomeStructural)
550        })? != self.codex_home_identity
551        {
552            return Err(configuration_refusal(
553                PreflightConfigurationRefusal::CodexHomeStructural,
554            ));
555        }
556
557        let fresh = Self::read(self.codex_home.clone())?;
558        if fresh.codex_home_identity != self.codex_home_identity {
559            return Err(configuration_refusal(
560                PreflightConfigurationRefusal::CodexHomeStructural,
561            ));
562        }
563        if fresh.config_identity != self.config_identity {
564            return Err(configuration_refusal(
565                PreflightConfigurationRefusal::ConfigFileIdentity,
566            ));
567        }
568        if fresh.config_sha256 != self.config_sha256 {
569            return Err(configuration_refusal(
570                PreflightConfigurationRefusal::ConfigContentDigest,
571            ));
572        }
573        if fresh.server_keys != self.server_keys
574            || fresh.configured_server_count != self.configured_server_count
575        {
576            return Err(configuration_refusal(
577                PreflightConfigurationRefusal::ConfigSemanticKeys,
578            ));
579        }
580        if fresh.override_set_sha256 != self.override_set_sha256 {
581            return Err(configuration_refusal(
582                PreflightConfigurationRefusal::ConfigOverrideDigest,
583            ));
584        }
585        Ok(())
586    }
587
588    fn attestation(&self) -> OwnedChildMcpIsolationAttestation {
589        OwnedChildMcpIsolationAttestation {
590            configured_server_count: self.configured_server_count,
591            config_sha256: self.config_sha256.clone(),
592            override_set_sha256: self.override_set_sha256.clone(),
593        }
594    }
595}
596
597fn codex_home_from_environment() -> Result<PathBuf, AppServerProcessError> {
598    let configured = std::env::var_os("CODEX_HOME").filter(|value| !value.is_empty());
599    let home = match configured {
600        Some(path) => PathBuf::from(path),
601        None => PathBuf::from(
602            std::env::var_os("HOME").ok_or(AppServerProcessError::InvalidConfiguration)?,
603        )
604        .join(".codex"),
605    };
606    if !home.is_absolute() {
607        return Err(AppServerProcessError::InvalidConfiguration);
608    }
609    Ok(home)
610}
611
612fn open_owned_directory(path: &Path) -> Result<fs::File, AppServerProcessError> {
613    if !path.is_absolute()
614        || path
615            .components()
616            .any(|component| matches!(component, Component::ParentDir | Component::CurDir))
617    {
618        return Err(AppServerProcessError::InvalidConfiguration);
619    }
620    let mut current = fs::File::from(
621        open(
622            "/",
623            OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
624            Mode::empty(),
625        )
626        .map_err(invalid)?,
627    );
628    for component in path.components() {
629        let Component::Normal(component) = component else {
630            continue;
631        };
632        current = fs::File::from(
633            openat(
634                &current,
635                component,
636                OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
637                Mode::empty(),
638            )
639            .map_err(invalid)?,
640        );
641    }
642    validate_owned_directory(&fstat(&current).map_err(invalid)?)?;
643    Ok(current)
644}
645
646fn validate_owned_directory(stat: &Stat) -> Result<(), AppServerProcessError> {
647    #[cfg(unix)]
648    if FileType::from_raw_mode(stat.st_mode) != FileType::Directory
649        || stat.st_uid != rustix::process::geteuid().as_raw()
650        || stat.st_mode & 0o022 != 0
651    {
652        return Err(AppServerProcessError::InvalidConfiguration);
653    }
654    #[cfg(not(unix))]
655    return Err(AppServerProcessError::InvalidConfiguration);
656    Ok(())
657}
658
659fn validate_owned_regular_file(
660    stat: &Stat,
661    maximum_size: Option<usize>,
662) -> Result<(), AppServerProcessError> {
663    #[cfg(unix)]
664    if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile
665        || stat.st_uid != rustix::process::geteuid().as_raw()
666        || stat.st_mode & 0o022 != 0
667        || stat.st_nlink != 1
668        || maximum_size.is_some_and(|maximum| stat.st_size > maximum as _)
669    {
670        return Err(AppServerProcessError::InvalidConfiguration);
671    }
672    #[cfg(not(unix))]
673    return Err(AppServerProcessError::InvalidConfiguration);
674    Ok(())
675}
676
677// rustix Stat field widths differ by platform: Linux reports device as u64,
678// mode as u32, and link count as u64, while macOS reports i32, u16, and u16.
679// The pinned identity commits fixed widths (u64/u32/u64).  These helpers
680// absorb the platform width so the direct-width platform takes no conversion
681// (a `try_from`/`from` that is a no-op is a clippy `useless_conversion`).
682
683#[cfg(target_os = "linux")]
684fn identity_device(stat: &Stat) -> Result<u64, AppServerProcessError> {
685    Ok(stat.st_dev)
686}
687
688#[cfg(not(target_os = "linux"))]
689fn identity_device(stat: &Stat) -> Result<u64, AppServerProcessError> {
690    u64::try_from(stat.st_dev).map_err(|_| AppServerProcessError::InvalidConfiguration)
691}
692
693#[cfg(target_os = "linux")]
694fn identity_mode(stat: &Stat) -> u32 {
695    stat.st_mode
696}
697
698#[cfg(not(target_os = "linux"))]
699fn identity_mode(stat: &Stat) -> u32 {
700    u32::from(stat.st_mode)
701}
702
703#[cfg(target_os = "linux")]
704fn identity_nlink(stat: &Stat) -> u64 {
705    stat.st_nlink
706}
707
708#[cfg(not(target_os = "linux"))]
709fn identity_nlink(stat: &Stat) -> u64 {
710    u64::from(stat.st_nlink)
711}
712
713fn pinned_identity(stat: &Stat) -> Result<PinnedFilesystemIdentity, AppServerProcessError> {
714    // rustix types st_mtime_nsec as u64 on Linux and i64 on macOS; the value
715    // is always a sub-second nanosecond count, so the conversion is lossless.
716    #[cfg(target_os = "linux")]
717    let modified_nanoseconds = i64::try_from(stat.st_mtime_nsec)
718        .map_err(|_| AppServerProcessError::InvalidConfiguration)?;
719    #[cfg(not(target_os = "linux"))]
720    let modified_nanoseconds = stat.st_mtime_nsec;
721    Ok(PinnedFilesystemIdentity {
722        device: identity_device(stat)?,
723        inode: stat.st_ino,
724        owner_uid: stat.st_uid,
725        mode: identity_mode(stat),
726        nlink: identity_nlink(stat),
727        size: u64::try_from(stat.st_size)
728            .map_err(|_| AppServerProcessError::InvalidConfiguration)?,
729        modified_seconds: stat.st_mtime,
730        modified_nanoseconds,
731    })
732}
733
734fn pinned_directory_identity(
735    stat: &Stat,
736) -> Result<PinnedDirectoryIdentity, AppServerProcessError> {
737    Ok(PinnedDirectoryIdentity {
738        device: identity_device(stat)?,
739        inode: stat.st_ino,
740        owner_uid: stat.st_uid,
741        // The raw mode includes the directory file-type bits.  Callers first
742        // validate that this is a private directory, so equality commits both
743        // type and permissions without treating mutable timestamps as identity.
744        mode: identity_mode(stat),
745    })
746}
747
748fn parse_mcp_server_keys(bytes: &[u8]) -> Result<Vec<String>, AppServerProcessError> {
749    let text = std::str::from_utf8(bytes)
750        .map_err(|_| configuration_refusal(PreflightConfigurationRefusal::ConfigSemanticKeys))?;
751    // Parse the already descriptor-pinned bytes using the same TOML grammar
752    // accepted by Codex. A literal-header scan misses dotted assignments,
753    // inline parent tables, quoted keys, and whitespace-equivalent headers.
754    // We inspect only `mcp_servers` shape and keys; command/env/credential
755    // values are never projected into an argument, receipt, or diagnostic.
756    let root = toml::from_str::<toml::Value>(text)
757        .map_err(|_| configuration_refusal(PreflightConfigurationRefusal::ConfigSemanticKeys))?;
758    let root = root
759        .as_table()
760        .ok_or_else(|| configuration_refusal(PreflightConfigurationRefusal::ConfigSemanticKeys))?;
761    let Some(servers) = root.get("mcp_servers") else {
762        return Ok(Vec::new());
763    };
764    let servers = servers
765        .as_table()
766        .ok_or_else(|| configuration_refusal(PreflightConfigurationRefusal::ConfigSemanticKeys))?;
767    if servers.len() > MAX_MCP_SERVER_KEYS {
768        return Err(configuration_refusal(
769            PreflightConfigurationRefusal::ConfigArgCap,
770        ));
771    }
772    let mut keys = Vec::with_capacity(servers.len());
773    for (key, server) in servers {
774        if !valid_mcp_server_key(key) || !server.is_table() {
775            return Err(configuration_refusal(
776                PreflightConfigurationRefusal::ConfigSemanticKeys,
777            ));
778        }
779        keys.push(key.clone());
780    }
781    keys.sort_unstable();
782    keys.dedup();
783    Ok(keys)
784}
785
786fn valid_mcp_server_key(key: &str) -> bool {
787    !key.is_empty()
788        && key.len() <= MAX_MCP_SERVER_KEY_BYTES
789        && key
790            .bytes()
791            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
792}
793
794fn mcp_override_set_sha256(server_keys: &[String]) -> String {
795    let mut digest = sha2::Sha256::new();
796    digest.update(b"steadymux.owned-child.mcp-disable-overrides.v1\0");
797    for key in server_keys {
798        digest.update(key.as_bytes());
799        digest.update([0]);
800    }
801    hex_digest(digest.finalize())
802}
803
804fn sha256_bytes(bytes: &[u8]) -> String {
805    hex_digest(sha2::Sha256::digest(bytes))
806}
807
808fn hex_digest(digest: impl AsRef<[u8]>) -> String {
809    let mut output = String::with_capacity(64);
810    for byte in digest.as_ref() {
811        use std::fmt::Write as _;
812        write!(&mut output, "{byte:02x}").expect("writing to String cannot fail");
813    }
814    output
815}
816
817fn invalid(_: impl std::fmt::Debug) -> AppServerProcessError {
818    AppServerProcessError::InvalidConfiguration
819}
820
821fn configuration_refusal(class: PreflightConfigurationRefusal) -> AppServerProcessError {
822    AppServerProcessError::PreflightConfiguration(class)
823}
824
825fn capture_executable_identity(
826    program: &std::path::Path,
827) -> Result<ExecutableIdentity, AppServerProcessError> {
828    let before = fs::metadata(program).map_err(|_| AppServerProcessError::InvalidConfiguration)?;
829    if !before.is_file() {
830        return Err(AppServerProcessError::InvalidConfiguration);
831    }
832    #[cfg(unix)]
833    {
834        let current_uid = rustix::process::geteuid().as_raw();
835        if before.nlink() != 1
836            || (before.uid() != 0 && before.uid() != current_uid)
837            || before.mode() & 0o022 != 0
838            || before.mode() & 0o111 == 0
839        {
840            return Err(AppServerProcessError::InvalidConfiguration);
841        }
842    }
843    #[cfg(not(unix))]
844    {
845        return Err(AppServerProcessError::InvalidConfiguration);
846    }
847    let sha256 = sha256_file(program)?;
848    let after = fs::metadata(program).map_err(|_| AppServerProcessError::InvalidConfiguration)?;
849    #[cfg(unix)]
850    if before.dev() != after.dev()
851        || before.ino() != after.ino()
852        || before.uid() != after.uid()
853        || before.mode() != after.mode()
854        || before.nlink() != after.nlink()
855        || before.len() != after.len()
856        || before.mtime() != after.mtime()
857        || before.mtime_nsec() != after.mtime_nsec()
858    {
859        return Err(AppServerProcessError::InvalidConfiguration);
860    }
861    Ok(ExecutableIdentity {
862        sha256,
863        #[cfg(unix)]
864        device: after.dev(),
865        #[cfg(unix)]
866        inode: after.ino(),
867        #[cfg(unix)]
868        owner_uid: after.uid(),
869        #[cfg(unix)]
870        mode: after.mode(),
871        #[cfg(unix)]
872        nlink: after.nlink(),
873        #[cfg(unix)]
874        size: after.len(),
875        #[cfg(unix)]
876        modified_seconds: after.mtime(),
877        #[cfg(unix)]
878        modified_nanoseconds: after.mtime_nsec(),
879    })
880}
881
882fn valid_sha256(value: &str) -> bool {
883    value.len() == 64
884        && value
885            .bytes()
886            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
887}
888
889fn sha256_file(program: &std::path::Path) -> Result<String, AppServerProcessError> {
890    let mut file =
891        fs::File::open(program).map_err(|_| AppServerProcessError::InvalidConfiguration)?;
892    let mut digest = sha2::Sha256::new();
893    let mut buffer = [0_u8; 64 * 1024];
894    loop {
895        let read = file
896            .read(&mut buffer)
897            .map_err(|_| AppServerProcessError::InvalidConfiguration)?;
898        if read == 0 {
899            break;
900        }
901        digest.update(&buffer[..read]);
902    }
903    let mut output = String::with_capacity(64);
904    for byte in digest.finalize() {
905        use std::fmt::Write as _;
906        write!(&mut output, "{byte:02x}").expect("writing to String cannot fail");
907    }
908    Ok(output)
909}
910
911#[derive(Clone, Copy, Debug, PartialEq, Eq)]
912pub struct ShutdownReceipt {
913    pub forced: bool,
914    pub success: bool,
915}
916
917#[derive(Debug, Error, PartialEq, Eq)]
918pub enum AppServerProcessError {
919    #[error("the owned app-server configuration is invalid")]
920    InvalidConfiguration,
921    #[error("the owned app-server configuration was refused before spawn")]
922    PreflightConfiguration(PreflightConfigurationRefusal),
923    #[error("the owned app-server process could not be started")]
924    SpawnFailed,
925    #[error("the owned app-server executable was unavailable")]
926    SpawnExecutableUnavailable,
927    #[error("the owned app-server executable was not permitted")]
928    SpawnExecutablePermissionDenied,
929    #[error("the owned app-server stdio could not be established")]
930    SpawnStdioUnavailable,
931    #[error("the owned app-server reader task could not be established")]
932    SpawnReaderUnavailable,
933    #[error("the Codex executable version is not supported by this adapter")]
934    UnsupportedExecutableVersion,
935    #[error("the owned app-server transport failed")]
936    TransportFailed,
937    #[error("the owned app-server emitted an oversized frame")]
938    FrameTooLarge,
939    #[error("the owned app-server closed its stream unexpectedly")]
940    UnexpectedEof,
941    #[error("the owned app-server operation timed out")]
942    Timeout,
943    #[error("the owned app-server exited before completing the operation")]
944    ProcessExited,
945    #[error("the turn outcome is ambiguous after a request write and replay is forbidden")]
946    AmbiguousAfterWriteNoReplay,
947    #[error("the app-server rejected the JSON-RPC request after write; replay is forbidden")]
948    PostWriteJsonRpcRejectedNoReplay,
949    #[error("the app-server emitted a side-effecting item after write; replay is forbidden")]
950    PostWriteSideEffectRejectedNoReplay,
951    #[error(
952        "the app-server notification or protocol state mismatched after write; replay is forbidden"
953    )]
954    PostWriteNotificationOrStateMismatchNoReplay,
955    #[error("the app-server timed out after request write; replay is forbidden")]
956    PostWriteTimeoutNoReplay,
957    #[error("the app-server stream ended after request write; replay is forbidden")]
958    PostWriteUnexpectedEofNoReplay,
959    #[error("the app-server transport failed after request write; replay is forbidden")]
960    PostWriteTransportNoReplay,
961    #[error("the app-server capacity bound was exceeded after request write; replay is forbidden")]
962    PostWriteCapacityExceededNoReplay,
963    #[error("the app-server protocol state failed")]
964    Runtime(#[from] RuntimeError),
965    #[error("the app-server selected a different model than requested")]
966    ModelRerouted,
967    #[error("the owned app-server shutdown could not be completed")]
968    ShutdownFailed,
969    #[error("the app-server discovery response exceeded configured page bounds")]
970    DiscoveryPageLimit,
971    #[error("the app-server event buffer is exhausted")]
972    EventCapacityExceeded,
973    #[error("the app-server terminal status does not match the requested operation")]
974    UnexpectedTerminalStatus,
975}
976
977impl AppServerProcessError {
978    /// Fixed, content-free class for the owned-child preflight boundary.
979    ///
980    /// This deliberately never includes a path, argv element, operating-system
981    /// error text, child output, or configuration fact.  It exists solely to
982    /// distinguish the fail-closed boundaries needed to diagnose an owned
983    /// child that could not reach JSON-RPC initialization.
984    pub const fn preflight_refusal_class(&self) -> &'static str {
985        match self {
986            Self::InvalidConfiguration => PreflightConfigurationRefusal::Other.as_str(),
987            Self::PreflightConfiguration(class) => class.as_str(),
988            Self::SpawnExecutableUnavailable => "command-not-found",
989            Self::SpawnExecutablePermissionDenied => "command-permission",
990            Self::SpawnFailed => "command-other",
991            Self::SpawnStdioUnavailable => "stdio",
992            Self::SpawnReaderUnavailable => "reader",
993            Self::UnsupportedExecutableVersion => "version",
994            Self::ProcessExited | Self::UnexpectedEof => "child-exit",
995            Self::Timeout => "timeout",
996            Self::TransportFailed | Self::FrameTooLarge => "transport",
997            Self::EventCapacityExceeded => "capacity",
998            Self::ShutdownFailed => "shutdown",
999            Self::Runtime(_) => "protocol",
1000            Self::AmbiguousAfterWriteNoReplay
1001            | Self::PostWriteJsonRpcRejectedNoReplay
1002            | Self::PostWriteSideEffectRejectedNoReplay
1003            | Self::PostWriteNotificationOrStateMismatchNoReplay
1004            | Self::PostWriteTimeoutNoReplay
1005            | Self::PostWriteUnexpectedEofNoReplay
1006            | Self::PostWriteTransportNoReplay
1007            | Self::PostWriteCapacityExceededNoReplay
1008            | Self::ModelRerouted
1009            | Self::DiscoveryPageLimit
1010            | Self::UnexpectedTerminalStatus => "postwrite-or-terminal",
1011        }
1012    }
1013}
1014
1015enum ProcessFrame {
1016    Line(Vec<u8>),
1017    Eof,
1018    TooLarge,
1019    Failed,
1020}
1021
1022/// A newly spawned app-server child with exclusively owned stdio handles.
1023pub struct OwnedAppServerTransport {
1024    child: Option<Child>,
1025    stdin: Option<ChildStdin>,
1026    frames: Receiver<ProcessFrame>,
1027    stdout_task: Option<JoinHandle<()>>,
1028    stderr_task: Option<JoinHandle<()>>,
1029    shutdown_timeout: Duration,
1030    max_line_bytes: usize,
1031    mcp_isolation_attestation: Option<OwnedChildMcpIsolationAttestation>,
1032    shutdown_receipt: Option<ShutdownReceipt>,
1033}
1034
1035impl fmt::Debug for OwnedAppServerTransport {
1036    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1037        formatter
1038            .debug_struct("OwnedAppServerTransport")
1039            .field("child_id", &self.child.as_ref().map(Child::id))
1040            .field("stdio", &"[OWNED]")
1041            .field("stopped", &self.shutdown_receipt.is_some())
1042            .finish()
1043    }
1044}
1045
1046impl OwnedAppServerTransport {
1047    pub fn spawn(config: &OwnedAppServerConfig) -> Result<Self, AppServerProcessError> {
1048        config.revalidate_before_spawn()?;
1049        let mcp_isolation_attestation = config.mcp_isolation_attestation();
1050        let mut command = Command::new(&config.program);
1051        command
1052            .args(&config.args)
1053            .stdin(Stdio::piped())
1054            .stdout(Stdio::piped())
1055            .stderr(Stdio::piped());
1056        configure_managed_child_env(&mut command, config.managed_codex_home());
1057
1058        let mut child = command.spawn().map_err(|error| match error.kind() {
1059            std::io::ErrorKind::NotFound => AppServerProcessError::SpawnExecutableUnavailable,
1060            std::io::ErrorKind::PermissionDenied => {
1061                AppServerProcessError::SpawnExecutablePermissionDenied
1062            }
1063            _ => AppServerProcessError::SpawnFailed,
1064        })?;
1065        let Some(stdin) = child.stdin.take() else {
1066            kill_and_reap(&mut child);
1067            return Err(AppServerProcessError::SpawnStdioUnavailable);
1068        };
1069        let Some(stdout) = child.stdout.take() else {
1070            kill_and_reap(&mut child);
1071            return Err(AppServerProcessError::SpawnStdioUnavailable);
1072        };
1073        let Some(stderr) = child.stderr.take() else {
1074            kill_and_reap(&mut child);
1075            return Err(AppServerProcessError::SpawnStdioUnavailable);
1076        };
1077        let (sender, frames) = mpsc::sync_channel(FRAME_CHANNEL_CAPACITY);
1078        let max_line_bytes = config.max_line_bytes;
1079        let stdout_task = match thread::Builder::new()
1080            .name("steadymux-codex-app-server-stdout".to_owned())
1081            .spawn(move || read_stdout(stdout, sender, max_line_bytes))
1082        {
1083            Ok(task) => task,
1084            Err(_) => {
1085                kill_and_reap(&mut child);
1086                return Err(AppServerProcessError::SpawnReaderUnavailable);
1087            }
1088        };
1089        let stderr_task = match thread::Builder::new()
1090            .name("steadymux-codex-app-server-stderr".to_owned())
1091            .spawn(move || discard_stderr(stderr))
1092        {
1093            Ok(task) => task,
1094            Err(_) => {
1095                kill_and_reap(&mut child);
1096                let _ = stdout_task.join();
1097                return Err(AppServerProcessError::SpawnReaderUnavailable);
1098            }
1099        };
1100        Ok(Self {
1101            child: Some(child),
1102            stdin: Some(stdin),
1103            frames,
1104            stdout_task: Some(stdout_task),
1105            stderr_task: Some(stderr_task),
1106            shutdown_timeout: config.shutdown_timeout,
1107            max_line_bytes: config.max_line_bytes,
1108            mcp_isolation_attestation,
1109            shutdown_receipt: None,
1110        })
1111    }
1112
1113    /// Returns the sealed MCP isolation state revalidated immediately before
1114    /// this owned child was created. It intentionally excludes server names
1115    /// and all Codex configuration values.
1116    pub fn mcp_isolation_attestation(&self) -> Option<&OwnedChildMcpIsolationAttestation> {
1117        self.mcp_isolation_attestation.as_ref()
1118    }
1119
1120    pub fn child_id(&self) -> Option<u32> {
1121        self.child.as_ref().map(Child::id)
1122    }
1123
1124    fn child_exited(&mut self) -> Result<bool, AppServerProcessError> {
1125        self.child
1126            .as_mut()
1127            .ok_or(AppServerProcessError::ProcessExited)?
1128            .try_wait()
1129            .map(|status| status.is_some())
1130            .map_err(|_| AppServerProcessError::TransportFailed)
1131    }
1132
1133    fn stop_child(&mut self) -> Result<ShutdownReceipt, AppServerProcessError> {
1134        if let Some(receipt) = self.shutdown_receipt {
1135            return Ok(receipt);
1136        }
1137        self.stdin.take();
1138        let deadline = Instant::now() + self.shutdown_timeout;
1139        let mut forced = false;
1140        let status = loop {
1141            let child = self
1142                .child
1143                .as_mut()
1144                .ok_or(AppServerProcessError::ShutdownFailed)?;
1145            match child
1146                .try_wait()
1147                .map_err(|_| AppServerProcessError::ShutdownFailed)?
1148            {
1149                Some(status) => break status,
1150                None if Instant::now() < deadline => thread::sleep(Duration::from_millis(5)),
1151                None => {
1152                    forced = true;
1153                    child
1154                        .kill()
1155                        .map_err(|_| AppServerProcessError::ShutdownFailed)?;
1156                    break child
1157                        .wait()
1158                        .map_err(|_| AppServerProcessError::ShutdownFailed)?;
1159                }
1160            }
1161        };
1162        self.child.take();
1163        let reader_deadline = Instant::now() + self.shutdown_timeout;
1164        let stdout_stopped = join_reader_until(self.stdout_task.take(), reader_deadline);
1165        let stderr_stopped = join_reader_until(self.stderr_task.take(), reader_deadline);
1166        let readers_stopped = stdout_stopped && stderr_stopped;
1167        let receipt = ShutdownReceipt {
1168            forced,
1169            success: exit_is_success_or_forced(status, forced) && readers_stopped,
1170        };
1171        self.shutdown_receipt = Some(receipt);
1172        Ok(receipt)
1173    }
1174}
1175
1176impl AppServerLineTransport for OwnedAppServerTransport {
1177    fn send_line(&mut self, line: &str) -> Result<(), AppServerProcessError> {
1178        let Some(body) = line.strip_suffix('\n') else {
1179            return Err(AppServerProcessError::TransportFailed);
1180        };
1181        if self.shutdown_receipt.is_some()
1182            || body.is_empty()
1183            || body.len() > self.max_line_bytes
1184            || body.bytes().any(|byte| matches!(byte, b'\n' | b'\r' | 0))
1185        {
1186            return Err(AppServerProcessError::TransportFailed);
1187        }
1188        if self.child_exited()? {
1189            return Err(AppServerProcessError::ProcessExited);
1190        }
1191        let stdin = self
1192            .stdin
1193            .as_mut()
1194            .ok_or(AppServerProcessError::ProcessExited)?;
1195        stdin
1196            .write_all(line.as_bytes())
1197            .and_then(|()| stdin.flush())
1198            .map_err(|_| AppServerProcessError::TransportFailed)
1199    }
1200
1201    fn recv_line(&mut self, timeout: Duration) -> Result<Vec<u8>, AppServerProcessError> {
1202        if timeout.is_zero() || timeout > MAX_TIMEOUT {
1203            return Err(AppServerProcessError::InvalidConfiguration);
1204        }
1205        match self.frames.recv_timeout(timeout) {
1206            Ok(ProcessFrame::Line(line)) => Ok(line),
1207            Ok(ProcessFrame::Eof) => Err(AppServerProcessError::UnexpectedEof),
1208            Ok(ProcessFrame::TooLarge) => Err(AppServerProcessError::FrameTooLarge),
1209            Ok(ProcessFrame::Failed) => Err(AppServerProcessError::TransportFailed),
1210            Err(RecvTimeoutError::Timeout) => Err(AppServerProcessError::Timeout),
1211            Err(RecvTimeoutError::Disconnected) => Err(AppServerProcessError::UnexpectedEof),
1212        }
1213    }
1214
1215    fn shutdown(&mut self) -> Result<ShutdownReceipt, AppServerProcessError> {
1216        self.stop_child()
1217    }
1218}
1219
1220impl Drop for OwnedAppServerTransport {
1221    fn drop(&mut self) {
1222        let _ = self.stop_child();
1223    }
1224}
1225
1226#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
1227pub struct AppServerDiscoveryReceipt {
1228    pub schema_version: String,
1229    pub required_cli_version: String,
1230    pub protocol_version: String,
1231    pub account: ManagedAccountProjection,
1232    pub model_pages: Vec<ModelPageProjection>,
1233    pub rate_limits: RateLimitsProjection,
1234}
1235
1236#[derive(Clone, Debug, PartialEq, Eq)]
1237pub struct ActiveCodexTurn {
1238    pub thread_id: String,
1239    pub turn_id: String,
1240    pub model: String,
1241    pub effort: ReasoningEffort,
1242}
1243
1244#[derive(Clone, Debug, PartialEq, Eq)]
1245pub struct TurnRunReceipt {
1246    pub events: Vec<RuntimeEvent>,
1247    pub completion: TurnCompletionReceipt,
1248}
1249
1250/// Drives the pure transcript state machine through an injected line transport.
1251pub struct CodexAppServerClient<T: AppServerLineTransport> {
1252    runtime: CodexAgentRuntime,
1253    transport: T,
1254    operation_timeout: Duration,
1255    buffered: VecDeque<RuntimeEvent>,
1256    required_cli_version: Option<String>,
1257    #[cfg(test)]
1258    turn_operation_timeout: Option<Duration>,
1259}
1260
1261impl<T: AppServerLineTransport> fmt::Debug for CodexAppServerClient<T> {
1262    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1263        formatter
1264            .debug_struct("CodexAppServerClient")
1265            .field("runtime", &self.runtime)
1266            .field("transport", &"[REDACTED]")
1267            .field("operation_timeout", &self.operation_timeout)
1268            .field("buffered_events", &self.buffered.len())
1269            .finish()
1270    }
1271}
1272
1273impl CodexAppServerClient<OwnedAppServerTransport> {
1274    pub fn spawn(config: &OwnedAppServerConfig) -> Result<Self, AppServerProcessError> {
1275        Self::spawn_with_tool_policy(config, CodexToolPolicy::RejectAll)
1276    }
1277
1278    pub fn spawn_with_tool_policy(
1279        config: &OwnedAppServerConfig,
1280        tool_policy: CodexToolPolicy,
1281    ) -> Result<Self, AppServerProcessError> {
1282        let transport = OwnedAppServerTransport::spawn(config)?;
1283        let mut client =
1284            Self::new_with_tool_policy(transport, config.operation_timeout, tool_policy)?;
1285        if let Some(version) = &config.required_app_server_version {
1286            client.required_cli_version = Some(version.clone());
1287        }
1288        Ok(client)
1289    }
1290}
1291
1292impl<T: AppServerLineTransport> CodexAppServerClient<T> {
1293    pub fn new(transport: T, operation_timeout: Duration) -> Result<Self, AppServerProcessError> {
1294        Self::new_with_tool_policy(transport, operation_timeout, CodexToolPolicy::RejectAll)
1295    }
1296
1297    pub fn new_with_tool_policy(
1298        transport: T,
1299        operation_timeout: Duration,
1300        tool_policy: CodexToolPolicy,
1301    ) -> Result<Self, AppServerProcessError> {
1302        if !valid_timeout(operation_timeout) {
1303            return Err(AppServerProcessError::InvalidConfiguration);
1304        }
1305        Ok(Self {
1306            runtime: CodexAgentRuntime::with_tool_policy(tool_policy),
1307            transport,
1308            operation_timeout,
1309            buffered: VecDeque::new(),
1310            required_cli_version: None,
1311            #[cfg(test)]
1312            turn_operation_timeout: None,
1313        })
1314    }
1315
1316    /// Require the exact reviewed standalone CLI version during initialize.
1317    /// Injected transcript transports use this same guard as owned processes.
1318    pub fn require_reviewed_cli_version(&mut self) {
1319        self.required_cli_version = Some(CURRENT_STANDALONE_CLI_VERSION.to_owned());
1320    }
1321
1322    /// Requires the already pinned executable's reviewed app-server version.
1323    /// This is intentionally a closed string supplied by the owned-child
1324    /// configuration, never by a provider response or request payload.
1325    pub fn require_app_server_version(
1326        &mut self,
1327        version: &str,
1328    ) -> Result<(), AppServerProcessError> {
1329        if version.is_empty() || version.len() > 128 || version.chars().any(char::is_control) {
1330            return Err(AppServerProcessError::InvalidConfiguration);
1331        }
1332        self.required_cli_version = Some(version.to_owned());
1333        Ok(())
1334    }
1335
1336    pub fn initialize(&mut self) -> Result<(), AppServerProcessError> {
1337        let request = self.runtime.initialize_request()?;
1338        self.send_and_wait(&request, |event| {
1339            matches!(event, RuntimeEvent::InitializeAccepted)
1340        })?;
1341        if self
1342            .required_cli_version
1343            .as_deref()
1344            .is_some_and(|required| self.runtime.initialized_server_version() != Some(required))
1345        {
1346            return self.fail(AppServerProcessError::UnsupportedExecutableVersion);
1347        }
1348        let notification = self.runtime.initialized_notification()?;
1349        match self.transport.send_line(&notification) {
1350            Ok(()) => Ok(()),
1351            Err(error) => self.fail(error),
1352        }
1353    }
1354
1355    pub fn discover(&mut self) -> Result<AppServerDiscoveryReceipt, AppServerProcessError> {
1356        let request = self.runtime.account_read_request()?;
1357        let account = match self.send_and_wait(&request, |event| {
1358            matches!(event, RuntimeEvent::ManagedAccount(_))
1359        })? {
1360            RuntimeEvent::ManagedAccount(account) => account,
1361            _ => {
1362                return Err(AppServerProcessError::Runtime(
1363                    RuntimeError::ProtocolFailure,
1364                ));
1365            }
1366        };
1367
1368        let mut model_pages = Vec::new();
1369        let mut cursor = None;
1370        loop {
1371            if model_pages.len() >= MAX_DISCOVERY_PAGES {
1372                return self.fail(AppServerProcessError::DiscoveryPageLimit);
1373            }
1374            let request = self.runtime.model_list_request(cursor.as_deref())?;
1375            let page = match self.send_and_wait(&request, |event| {
1376                matches!(event, RuntimeEvent::ModelPage(_))
1377            })? {
1378                RuntimeEvent::ModelPage(page) => page,
1379                _ => {
1380                    return Err(AppServerProcessError::Runtime(
1381                        RuntimeError::ProtocolFailure,
1382                    ));
1383                }
1384            };
1385            cursor.clone_from(&page.next_cursor);
1386            model_pages.push(page);
1387            if cursor.is_none() {
1388                break;
1389            }
1390        }
1391
1392        let request = self.runtime.rate_limits_read_request()?;
1393        let rate_limits = match self.send_and_wait(&request, |event| {
1394            matches!(event, RuntimeEvent::RateLimits(_))
1395        })? {
1396            RuntimeEvent::RateLimits(rate_limits) => rate_limits,
1397            _ => {
1398                return Err(AppServerProcessError::Runtime(
1399                    RuntimeError::ProtocolFailure,
1400                ));
1401            }
1402        };
1403        Ok(AppServerDiscoveryReceipt {
1404            schema_version: APP_SERVER_DISCOVERY_SCHEMA_VERSION.to_owned(),
1405            // The initialize response is the only version value that has
1406            // actually crossed the owned transport and passed the configured
1407            // version guard. Never report a global default here: Desktop and
1408            // standalone owned children have distinct reviewed versions.
1409            required_cli_version: self
1410                .runtime
1411                .initialized_server_version()
1412                .ok_or(AppServerProcessError::Runtime(
1413                    RuntimeError::ProtocolFailure,
1414                ))?
1415                .to_owned(),
1416            protocol_version: CODEX_APP_SERVER_PROTOCOL_VERSION.to_owned(),
1417            account,
1418            model_pages,
1419            rate_limits,
1420        })
1421    }
1422
1423    pub fn start_turn(
1424        &mut self,
1425        client_message_id: &str,
1426        text: &str,
1427        model: &str,
1428        effort: ReasoningEffort,
1429    ) -> Result<ActiveCodexTurn, AppServerProcessError> {
1430        self.start_turn_with_instructions(client_message_id, text, None, None, model, effort)
1431    }
1432
1433    pub fn start_turn_with_instructions(
1434        &mut self,
1435        client_message_id: &str,
1436        text: &str,
1437        base_instructions: Option<&str>,
1438        developer_instructions: Option<&str>,
1439        model: &str,
1440        effort: ReasoningEffort,
1441    ) -> Result<ActiveCodexTurn, AppServerProcessError> {
1442        eprintln!("steadymux-owned-child: stage=thread-request");
1443        let request = self.runtime.thread_start_request_with_instructions(
1444            model,
1445            effort,
1446            base_instructions,
1447            developer_instructions,
1448        )?;
1449        eprintln!("steadymux-owned-child: stage=thread-wait");
1450        let thread_id = match self.send_and_wait(&request, |event| {
1451            matches!(event, RuntimeEvent::ThreadStarted { .. })
1452        })? {
1453            RuntimeEvent::ThreadStarted { thread_id } => thread_id,
1454            _ => {
1455                return Err(AppServerProcessError::Runtime(
1456                    RuntimeError::ProtocolFailure,
1457                ));
1458            }
1459        };
1460        eprintln!("steadymux-owned-child: stage=thread-accepted");
1461        // Test seam: timeout fixtures run thread/start under the fixture's
1462        // generous setup window, then clamp the operation timeout to the tight
1463        // value only for the turn/start wait. This keeps the wall-clock setup
1464        // latency entirely outside the timeout under test.
1465        #[cfg(test)]
1466        if let Some(turn_timeout) = self.turn_operation_timeout {
1467            self.operation_timeout = turn_timeout;
1468        }
1469        eprintln!("steadymux-owned-child: stage=turn-request");
1470        let request =
1471            self.runtime
1472                .turn_start_request(&thread_id, client_message_id, text, model, effort)?;
1473        eprintln!("steadymux-owned-child: stage=turn-wait");
1474        let turn_response = match self.send_and_wait(&request, |event| {
1475            matches!(event, RuntimeEvent::TurnAccepted { .. })
1476        }) {
1477            Ok(event) => event,
1478            Err(error) => return self.fail(classify_post_write_failure(error)),
1479        };
1480        let turn_id = match turn_response {
1481            RuntimeEvent::TurnAccepted {
1482                thread_id: response_thread,
1483                turn_id,
1484            } if response_thread == thread_id => turn_id,
1485            _ => {
1486                return self
1487                    .fail(AppServerProcessError::PostWriteNotificationOrStateMismatchNoReplay);
1488            }
1489        };
1490        Ok(ActiveCodexTurn {
1491            thread_id,
1492            turn_id,
1493            model: model.to_owned(),
1494            effort,
1495        })
1496    }
1497
1498    pub fn wait_for_terminal(
1499        &mut self,
1500        turn: &ActiveCodexTurn,
1501    ) -> Result<TurnRunReceipt, AppServerProcessError> {
1502        let mut events = Vec::new();
1503        let deadline = Instant::now() + self.operation_timeout;
1504        loop {
1505            let event = match self.pop_buffered_for_turn(turn) {
1506                Some(event) => event,
1507                None => match self.receive_event_until(deadline) {
1508                    Ok(event) => event,
1509                    Err(error) => {
1510                        return self.fail(classify_post_write_failure(error));
1511                    }
1512                },
1513            };
1514            if event_is_reroute_for(&event, turn) {
1515                return self.fail(AppServerProcessError::ModelRerouted);
1516            }
1517            if event_matches_turn(&event, turn) {
1518                if let RuntimeEvent::TurnCompleted(completion) = &event {
1519                    let completion = completion.clone();
1520                    events.push(event);
1521                    return Ok(TurnRunReceipt { events, completion });
1522                }
1523                if events.len() >= MAX_BUFFERED_EVENTS {
1524                    return self.fail(AppServerProcessError::PostWriteCapacityExceededNoReplay);
1525                }
1526                events.push(event);
1527            } else {
1528                if let Err(error) = self.push_buffered(event) {
1529                    return self.fail(classify_post_write_failure(error));
1530                }
1531            }
1532        }
1533    }
1534
1535    /// Wait for a terminal while honoring the caller's one cancellation
1536    /// authority. Cancellation after turn/start sends `turn/interrupt` on the
1537    /// same owned client and joins the terminal lifecycle before returning.
1538    pub fn wait_for_terminal_with_cancellation(
1539        &mut self,
1540        turn: &ActiveCodexTurn,
1541        cancelled: impl Fn() -> bool,
1542    ) -> Result<TurnRunReceipt, AppServerProcessError> {
1543        if cancelled() {
1544            return self.interrupt(turn);
1545        }
1546        let mut events = Vec::new();
1547        let deadline = Instant::now() + self.operation_timeout;
1548        loop {
1549            if cancelled() {
1550                return self.interrupt(turn);
1551            }
1552            let event = match self.pop_buffered_for_turn(turn) {
1553                Some(event) => event,
1554                None => match self.receive_event_until(deadline) {
1555                    Ok(event) => event,
1556                    Err(error) => return self.fail(classify_post_write_failure(error)),
1557                },
1558            };
1559            if event_is_reroute_for(&event, turn) {
1560                return self.fail(AppServerProcessError::ModelRerouted);
1561            }
1562            if event_matches_turn(&event, turn) {
1563                if let RuntimeEvent::TurnCompleted(completion) = &event {
1564                    let completion = completion.clone();
1565                    events.push(event);
1566                    return Ok(TurnRunReceipt { events, completion });
1567                }
1568                if events.len() >= MAX_BUFFERED_EVENTS {
1569                    return self.fail(AppServerProcessError::PostWriteCapacityExceededNoReplay);
1570                }
1571                events.push(event);
1572            } else if let Err(error) = self.push_buffered(event) {
1573                return self.fail(classify_post_write_failure(error));
1574            }
1575        }
1576    }
1577
1578    pub fn interrupt(
1579        &mut self,
1580        turn: &ActiveCodexTurn,
1581    ) -> Result<TurnRunReceipt, AppServerProcessError> {
1582        let request = self
1583            .runtime
1584            .turn_interrupt_request(&turn.thread_id, &turn.turn_id)?;
1585        if let Err(error) = self.send_and_wait(&request, |event| {
1586            matches!(
1587                event,
1588                RuntimeEvent::InterruptAcknowledged {
1589                    thread_id,
1590                    turn_id
1591                } if thread_id == &turn.thread_id && turn_id == &turn.turn_id
1592            )
1593        }) {
1594            return self.fail(classify_post_write_failure(error));
1595        }
1596        let receipt = self.wait_for_terminal(turn)?;
1597        if receipt.completion.status != TerminalStatus::Interrupted {
1598            return self.fail(AppServerProcessError::UnexpectedTerminalStatus);
1599        }
1600        Ok(receipt)
1601    }
1602
1603    pub fn shutdown(&mut self) -> Result<ShutdownReceipt, AppServerProcessError> {
1604        self.transport.shutdown()
1605    }
1606
1607    pub fn into_transport(self) -> T {
1608        self.transport
1609    }
1610
1611    fn send_and_wait(
1612        &mut self,
1613        request: &str,
1614        predicate: impl Fn(&RuntimeEvent) -> bool,
1615    ) -> Result<RuntimeEvent, AppServerProcessError> {
1616        if let Err(error) = self.transport.send_line(request) {
1617            return self.fail(error);
1618        }
1619        let deadline = Instant::now() + self.operation_timeout;
1620        loop {
1621            let event = self.receive_event_until(deadline)?;
1622            if predicate(&event) {
1623                return Ok(event);
1624            }
1625            self.push_buffered(event)?;
1626        }
1627    }
1628
1629    fn pop_buffered_for_turn(&mut self, turn: &ActiveCodexTurn) -> Option<RuntimeEvent> {
1630        let position = self
1631            .buffered
1632            .iter()
1633            .position(|event| event_matches_turn(event, turn));
1634        position.and_then(|position| self.buffered.remove(position))
1635    }
1636
1637    fn receive_event_until(
1638        &mut self,
1639        deadline: Instant,
1640    ) -> Result<RuntimeEvent, AppServerProcessError> {
1641        let mut ignored_non_semantic_notifications = 0usize;
1642        loop {
1643            let Some(remaining) = deadline.checked_duration_since(Instant::now()) else {
1644                return self.fail(AppServerProcessError::Timeout);
1645            };
1646            if remaining.is_zero() {
1647                return self.fail(AppServerProcessError::Timeout);
1648            }
1649            let line = match self.transport.recv_line(remaining) {
1650                Ok(line) => line,
1651                Err(error) => return self.fail(error),
1652            };
1653            match self.runtime.ingest_line(&line) {
1654                Ok(RuntimeEvent::NonSemanticNotificationIgnored) => {
1655                    let Some(next_count) = ignored_non_semantic_notifications.checked_add(1) else {
1656                        return self.fail(AppServerProcessError::EventCapacityExceeded);
1657                    };
1658                    ignored_non_semantic_notifications = next_count;
1659                    if ignored_non_semantic_notifications > MAX_IGNORED_NON_SEMANTIC_NOTIFICATIONS {
1660                        return self.fail(AppServerProcessError::EventCapacityExceeded);
1661                    }
1662                }
1663                Ok(event) => return Ok(event),
1664                Err(error) => {
1665                    log_runtime_failure(&error);
1666                    return self.fail(AppServerProcessError::Runtime(error));
1667                }
1668            }
1669        }
1670    }
1671
1672    fn push_buffered(&mut self, event: RuntimeEvent) -> Result<(), AppServerProcessError> {
1673        if self.buffered.len() >= MAX_BUFFERED_EVENTS {
1674            return self.fail(AppServerProcessError::EventCapacityExceeded);
1675        }
1676        self.buffered.push_back(event);
1677        Ok(())
1678    }
1679
1680    fn fail<R>(&mut self, error: AppServerProcessError) -> Result<R, AppServerProcessError> {
1681        let _ = self.transport.shutdown();
1682        Err(error)
1683    }
1684}
1685
1686fn classify_post_write_failure(error: AppServerProcessError) -> AppServerProcessError {
1687    match error {
1688        AppServerProcessError::Runtime(RuntimeError::JsonRpcRejected) => {
1689            AppServerProcessError::PostWriteJsonRpcRejectedNoReplay
1690        }
1691        AppServerProcessError::Runtime(RuntimeError::SideEffectingItemRejected) => {
1692            AppServerProcessError::PostWriteSideEffectRejectedNoReplay
1693        }
1694        AppServerProcessError::Runtime(_) => {
1695            AppServerProcessError::PostWriteNotificationOrStateMismatchNoReplay
1696        }
1697        AppServerProcessError::Timeout => AppServerProcessError::PostWriteTimeoutNoReplay,
1698        AppServerProcessError::UnexpectedEof => {
1699            AppServerProcessError::PostWriteUnexpectedEofNoReplay
1700        }
1701        AppServerProcessError::TransportFailed
1702        | AppServerProcessError::FrameTooLarge
1703        | AppServerProcessError::ProcessExited => AppServerProcessError::PostWriteTransportNoReplay,
1704        AppServerProcessError::EventCapacityExceeded => {
1705            AppServerProcessError::PostWriteCapacityExceededNoReplay
1706        }
1707        _ => AppServerProcessError::PostWriteNotificationOrStateMismatchNoReplay,
1708    }
1709}
1710
1711fn log_runtime_failure(error: &RuntimeError) {
1712    let class = match error {
1713        RuntimeError::UnsupportedNotificationMethod(method) => {
1714            let method_sha256 = sha2::Sha256::digest(method.as_bytes())
1715                .iter()
1716                .map(|byte| format!("{byte:02x}"))
1717                .collect::<String>();
1718            eprintln!(
1719                "steadymux-owned-child: refused=runtime class=unsupported-notification method-sha256={method_sha256}"
1720            );
1721            return;
1722        }
1723        RuntimeError::JsonRpcRejected => "json-rpc-rejected",
1724        RuntimeError::SensitiveInputRejected => "sensitive-input",
1725        RuntimeError::SideEffectingItemRejected => "side-effecting-item",
1726        RuntimeError::CorrelationMismatch => "correlation-mismatch",
1727        RuntimeError::ProtocolFailure => "protocol-failure",
1728        RuntimeError::InvalidInput => "invalid-input",
1729        RuntimeError::PendingCapacityExceeded => "pending-capacity",
1730        RuntimeError::OutputLimitExceeded => "output-limit",
1731        _ => "other",
1732    };
1733    eprintln!("steadymux-owned-child: refused=runtime class={class}");
1734}
1735
1736fn event_matches_turn(event: &RuntimeEvent, turn: &ActiveCodexTurn) -> bool {
1737    match event {
1738        RuntimeEvent::TurnAccepted { thread_id, turn_id }
1739        | RuntimeEvent::TurnStarted { thread_id, turn_id }
1740        | RuntimeEvent::ItemStarted {
1741            thread_id, turn_id, ..
1742        }
1743        | RuntimeEvent::ItemDelta {
1744            thread_id, turn_id, ..
1745        }
1746        | RuntimeEvent::ItemCompleted {
1747            thread_id, turn_id, ..
1748        }
1749        | RuntimeEvent::ToolCallStarted {
1750            thread_id, turn_id, ..
1751        }
1752        | RuntimeEvent::ToolCallCompleted {
1753            thread_id, turn_id, ..
1754        }
1755        | RuntimeEvent::InterruptAcknowledged { thread_id, turn_id }
1756        | RuntimeEvent::ModelReroutedFailure {
1757            thread_id, turn_id, ..
1758        } => thread_id == &turn.thread_id && turn_id == &turn.turn_id,
1759        RuntimeEvent::TurnCompleted(receipt) => {
1760            receipt.thread_id == turn.thread_id && receipt.turn_id == turn.turn_id
1761        }
1762        RuntimeEvent::InitializeAccepted
1763        | RuntimeEvent::NonSemanticNotificationIgnored
1764        | RuntimeEvent::ManagedAccount(_)
1765        | RuntimeEvent::ModelPage(_)
1766        | RuntimeEvent::RateLimits(_)
1767        | RuntimeEvent::ThreadStarted { .. } => false,
1768    }
1769}
1770
1771fn event_is_reroute_for(event: &RuntimeEvent, turn: &ActiveCodexTurn) -> bool {
1772    matches!(
1773        event,
1774        RuntimeEvent::ModelReroutedFailure {
1775            thread_id,
1776            turn_id,
1777            ..
1778        } if thread_id == &turn.thread_id && turn_id == &turn.turn_id
1779    )
1780}
1781
1782fn valid_arg(value: &OsStr) -> bool {
1783    let Some(value) = value.to_str() else {
1784        return false;
1785    };
1786    !value.is_empty()
1787        && value.len() <= MAX_ARG_BYTES
1788        && !value.contains('\0')
1789        && !contains_token_marker(value)
1790}
1791
1792fn valid_timeout(value: Duration) -> bool {
1793    (MIN_TIMEOUT..=MAX_TIMEOUT).contains(&value)
1794}
1795
1796fn configure_managed_child_env(command: &mut Command, pinned_codex_home: Option<&Path>) {
1797    // The child resolves managed authentication from its own HOME/CODEX_HOME.
1798    // Start from an empty environment so no raw token or unrelated secret can
1799    // cross this process boundary, then copy only non-secret runtime paths and
1800    // locale settings required by the CLI.
1801    command.env_clear();
1802    for name in MANAGED_CHILD_ENV_ALLOWLIST {
1803        if name == "CODEX_HOME" && pinned_codex_home.is_some() {
1804            continue;
1805        }
1806        if let Some(value) = std::env::var_os(name) {
1807            command.env(name, value);
1808        }
1809    }
1810    if let Some(codex_home) = pinned_codex_home {
1811        command.env("CODEX_HOME", codex_home);
1812    }
1813}
1814
1815fn contains_token_marker(value: &str) -> bool {
1816    let value = value.to_ascii_lowercase();
1817    [
1818        "access_token",
1819        "refresh_token",
1820        "authorization:",
1821        "bearer ",
1822        "api_key",
1823        "apikey",
1824        "sk-",
1825    ]
1826    .iter()
1827    .any(|marker| value.contains(marker))
1828}
1829
1830fn read_stdout(stdout: impl Read, sender: SyncSender<ProcessFrame>, max_line_bytes: usize) {
1831    let mut reader = BufReader::new(stdout);
1832    loop {
1833        let frame = match read_bounded_line(&mut reader, max_line_bytes) {
1834            Ok(Some(line)) => ProcessFrame::Line(line),
1835            Ok(None) => ProcessFrame::Eof,
1836            Err(LineReadError::TooLarge) => ProcessFrame::TooLarge,
1837            Err(LineReadError::Io) => ProcessFrame::Failed,
1838        };
1839        let terminal = !matches!(frame, ProcessFrame::Line(_));
1840        if sender.send(frame).is_err() || terminal {
1841            return;
1842        }
1843    }
1844}
1845
1846fn discard_stderr(mut stderr: impl Read) {
1847    let mut buffer = [0_u8; 4096];
1848    loop {
1849        match stderr.read(&mut buffer) {
1850            Ok(0) | Err(_) => return,
1851            Ok(_) => {}
1852        }
1853    }
1854}
1855
1856enum LineReadError {
1857    TooLarge,
1858    Io,
1859}
1860
1861fn read_bounded_line(
1862    reader: &mut impl BufRead,
1863    max_line_bytes: usize,
1864) -> Result<Option<Vec<u8>>, LineReadError> {
1865    let mut line = Vec::new();
1866    loop {
1867        let available = reader.fill_buf().map_err(|_| LineReadError::Io)?;
1868        if available.is_empty() {
1869            return if line.is_empty() {
1870                Ok(None)
1871            } else {
1872                Err(LineReadError::Io)
1873            };
1874        }
1875        if let Some(newline) = available.iter().position(|byte| *byte == b'\n') {
1876            if line.len().saturating_add(newline) > max_line_bytes {
1877                return Err(LineReadError::TooLarge);
1878            }
1879            line.extend_from_slice(&available[..newline]);
1880            reader.consume(newline + 1);
1881            return Ok(Some(line));
1882        }
1883        if line.len().saturating_add(available.len()) > max_line_bytes {
1884            return Err(LineReadError::TooLarge);
1885        }
1886        let consumed = available.len();
1887        line.extend_from_slice(available);
1888        reader.consume(consumed);
1889    }
1890}
1891
1892fn join_reader_until(task: Option<JoinHandle<()>>, deadline: Instant) -> bool {
1893    let Some(task) = task else {
1894        return true;
1895    };
1896    while !task.is_finished() && Instant::now() < deadline {
1897        thread::sleep(Duration::from_millis(5));
1898    }
1899    task.is_finished() && task.join().is_ok()
1900}
1901
1902fn kill_and_reap(child: &mut Child) {
1903    let _ = child.kill();
1904    let _ = child.wait();
1905}
1906
1907fn exit_is_success_or_forced(status: ExitStatus, forced: bool) -> bool {
1908    status.success() || forced
1909}
1910
1911#[cfg(test)]
1912mod tests {
1913    use super::*;
1914    use std::fs;
1915    use tempfile::TempDir;
1916
1917    fn reviewed_live_executable(sentinel: &str) -> PathBuf {
1918        assert_eq!(
1919            std::env::var(sentinel).as_deref(),
1920            Ok("1"),
1921            "set the test-specific live authorization sentinel explicitly"
1922        );
1923        std::env::var_os("STEADYMUX_CODEX_EXECUTABLE")
1924            .map(PathBuf::from)
1925            .expect("set an absolute reviewed Codex executable path")
1926    }
1927
1928    #[test]
1929    fn owned_child_preflight_classes_are_fixed_and_content_free() {
1930        let cases = [
1931            (AppServerProcessError::InvalidConfiguration, "config-other"),
1932            (
1933                AppServerProcessError::PreflightConfiguration(
1934                    PreflightConfigurationRefusal::CodexHomeStructural,
1935                ),
1936                "codex-home-structural",
1937            ),
1938            (
1939                AppServerProcessError::PreflightConfiguration(
1940                    PreflightConfigurationRefusal::ConfigOpenTypeOwnerModeLink,
1941                ),
1942                "config-open-type-owner-mode-link",
1943            ),
1944            (
1945                AppServerProcessError::PreflightConfiguration(
1946                    PreflightConfigurationRefusal::ConfigFileIdentity,
1947                ),
1948                "config-file-identity",
1949            ),
1950            (
1951                AppServerProcessError::PreflightConfiguration(
1952                    PreflightConfigurationRefusal::ConfigContentDigest,
1953                ),
1954                "config-content-digest",
1955            ),
1956            (
1957                AppServerProcessError::PreflightConfiguration(
1958                    PreflightConfigurationRefusal::ConfigSemanticKeys,
1959                ),
1960                "config-semantic-keys",
1961            ),
1962            (
1963                AppServerProcessError::PreflightConfiguration(
1964                    PreflightConfigurationRefusal::ConfigOverrideDigest,
1965                ),
1966                "config-override-digest",
1967            ),
1968            (
1969                AppServerProcessError::PreflightConfiguration(
1970                    PreflightConfigurationRefusal::ConfigArgCap,
1971                ),
1972                "config-arg-cap",
1973            ),
1974            (
1975                AppServerProcessError::SpawnExecutableUnavailable,
1976                "command-not-found",
1977            ),
1978            (
1979                AppServerProcessError::SpawnExecutablePermissionDenied,
1980                "command-permission",
1981            ),
1982            (AppServerProcessError::SpawnFailed, "command-other"),
1983            (AppServerProcessError::SpawnStdioUnavailable, "stdio"),
1984            (AppServerProcessError::SpawnReaderUnavailable, "reader"),
1985            (AppServerProcessError::ProcessExited, "child-exit"),
1986            (AppServerProcessError::UnexpectedEof, "child-exit"),
1987        ];
1988        for (error, class) in cases {
1989            assert_eq!(error.preflight_refusal_class(), class);
1990            assert!(
1991                error
1992                    .preflight_refusal_class()
1993                    .bytes()
1994                    .all(|byte| byte.is_ascii_lowercase() || byte == b'-'),
1995                "diagnostic class is fixed and does not project process data"
1996            );
1997        }
1998    }
1999
2000    /// Generous window for owned-fixture setup (spawn, initialize, discover,
2001    /// thread/start). The child process and its reader threads must come up
2002    /// and complete several round-trips inside this window; 2s absorbs the
2003    /// wall-clock scheduling latency that a tight timeout would race under
2004    /// parallel test load.
2005    #[cfg(unix)]
2006    const TIMEOUT_FIXTURE_SETUP_WINDOW: Duration = Duration::from_secs(2);
2007
2008    #[cfg(unix)]
2009    fn shell_fixture(script: &str, timeout: Duration) -> (TempDir, OwnedAppServerConfig) {
2010        let directory = TempDir::new().expect("temporary fake app-server directory");
2011        let script_path = directory.path().join("fake-app-server.sh");
2012        fs::write(&script_path, script).expect("write fake app-server script");
2013        let shell = std::path::Path::new("/bin/sh")
2014            .canonicalize()
2015            .expect("canonical shell");
2016        let config =
2017            OwnedAppServerConfig::fixture(&shell, vec![script_path.into_os_string()], timeout);
2018        (directory, config)
2019    }
2020
2021    #[cfg(unix)]
2022    fn instruction_trace_fixture() -> (TempDir, OwnedAppServerConfig, PathBuf) {
2023        let directory = TempDir::new().expect("temporary instruction fixture directory");
2024        let script_path = directory.path().join("instruction-app-server.sh");
2025        let trace = directory.path().join("thread-start.jsonl");
2026        let script = format!(
2027            r#"
2028while IFS= read -r line; do
2029  case "$line" in
2030    *'"method":"initialize"'*) printf '%s\n' '{{"id":1,"result":{{"userAgent":"steadymux/0.148.0"}}}}' ;;
2031    *'"method":"initialized"'*) ;;
2032    *'"method":"account/read"'*) printf '%s\n' '{{"id":2,"result":{{"account":{{"type":"chatgpt","planType":"pro"}},"requiresOpenaiAuth":true}}}}' ;;
2033    *'"method":"model/list"'*) printf '%s\n' '{{"id":3,"result":{{"data":[{{"id":"gpt-5.6-terra","displayName":"Terra","hidden":false,"supportedReasoningEfforts":[{{"reasoningEffort":"medium"}}]}}],"nextCursor":null}}}}' ;;
2034    *'"method":"account/rateLimits/read"'*) printf '%s\n' '{{"id":4,"result":{{"rateLimits":{{"primary":null,"secondary":null,"rateLimitReachedType":null}}}}}}' ;;
2035    *'"method":"thread/start"'*)
2036      printf '%s\n' "$line" > '{}'
2037      printf '%s\n' '{{"id":5,"result":{{"thread":{{"id":"thr-instructions"}}}}}}' ;;
2038    *'"method":"turn/start"'*) printf '%s\n' '{{"id":6,"result":{{"turn":{{"id":"turn-instructions","status":"inProgress","items":[],"error":null}}}}}}' ;;
2039  esac
2040done
2041"#,
2042            trace.display()
2043        );
2044        fs::write(&script_path, script).expect("write instruction fixture script");
2045        let shell = std::path::Path::new("/bin/sh")
2046            .canonicalize()
2047            .expect("canonical shell");
2048        let config = OwnedAppServerConfig::fixture(
2049            &shell,
2050            vec![script_path.into_os_string()],
2051            Duration::from_secs(2),
2052        );
2053        (directory, config, trace)
2054    }
2055
2056    #[cfg(unix)]
2057    fn attested_executable_fixture(version: &str) -> (TempDir, PathBuf, PathBuf) {
2058        use std::os::unix::fs::PermissionsExt;
2059
2060        let directory = TempDir::new().expect("temporary executable directory");
2061        let executable = directory.path().join("codex-fixture");
2062        let trace = directory.path().join("invocations.log");
2063        let script = format!(
2064            "#!/bin/sh\nprintf 'argv:%s\\n' \"$*\" >> '{}'\nwhile IFS= read -r line; do\n  printf '%s\\n' \"$line\" >> '{}'\n  case \"$line\" in\n    *'\"method\":\"initialize\"'*) printf '%s\\n' '{{\"id\":1,\"result\":{{\"userAgent\":\"steadymux/{} (fixture)\"}}}}' ;;\n    *'\"method\":\"model/list\"'*) printf '%s\\n' '{{\"id\":2,\"result\":{{\"data\":[],\"nextCursor\":null}}}}' ;;\n  esac\ndone\n",
2065            trace.display(),
2066            trace.display(),
2067            version
2068        );
2069        fs::write(&executable, script).expect("write executable fixture");
2070        let mut permissions = fs::metadata(&executable).unwrap().permissions();
2071        permissions.set_mode(0o700);
2072        fs::set_permissions(&executable, permissions).unwrap();
2073        (directory, executable, trace)
2074    }
2075
2076    #[cfg(unix)]
2077    fn mcp_config_home(directory: &TempDir, tables: &[&str]) -> PathBuf {
2078        use std::os::unix::fs::PermissionsExt as _;
2079
2080        let home = directory.path().join("codex-home");
2081        fs::create_dir(&home).expect("private Codex home");
2082        let mut home_permissions = fs::metadata(&home)
2083            .expect("Codex home metadata")
2084            .permissions();
2085        home_permissions.set_mode(0o700);
2086        fs::set_permissions(&home, home_permissions).expect("private Codex home mode");
2087        let config = tables.join("\n");
2088        fs::write(home.join("config.toml"), config).expect("MCP config fixture");
2089        let mut config_permissions = fs::metadata(home.join("config.toml"))
2090            .expect("MCP config metadata")
2091            .permissions();
2092        config_permissions.set_mode(0o600);
2093        fs::set_permissions(home.join("config.toml"), config_permissions)
2094            .expect("private MCP config mode");
2095        home.canonicalize().expect("canonical private Codex home")
2096    }
2097
2098    #[cfg(unix)]
2099    const COMPLETE_SCRIPT: &str = r#"
2100while IFS= read -r line; do
2101  case "$line" in
2102    *'"method":"initialize"'*) printf '%s\n' '{"id":1,"result":{"userAgent":"steadymux/0.148.0"}}' ;;
2103    *'"method":"initialized"'*) ;;
2104    *'"method":"account/read"'*) printf '%s\n' '{"id":2,"result":{"account":{"type":"chatgpt","email":"private@example.test","planType":"pro"},"requiresOpenaiAuth":true}}' ;;
2105    *'"method":"model/list"'*) printf '%s\n' '{"id":3,"result":{"data":[{"id":"gpt-5.6-terra","displayName":"Terra","hidden":false,"supportedReasoningEfforts":[{"reasoningEffort":"medium","description":"balanced"}]}],"nextCursor":null}}' ;;
2106    *'"method":"account/rateLimits/read"'*) printf '%s\n' '{"id":4,"result":{"rateLimits":{"primary":{"usedPercent":10,"windowDurationMins":15,"resetsAt":1730947200},"secondary":null,"rateLimitReachedType":null}}}' ;;
2107    *'"method":"thread/start"'*) printf '%s\n' '{"id":5,"result":{"thread":{"id":"thr-fixture"}}}' ;;
2108    *'"method":"turn/start"'*)
2109      printf '%s\n' '{"id":6,"result":{"turn":{"id":"turn-fixture","status":"inProgress","items":[],"error":null}}}'
2110      printf '%s\n' '{"method":"turn/started","params":{"threadId":"thr-fixture","turn":{"id":"turn-fixture","status":"inProgress","items":[],"error":null}}}'
2111      printf '%s\n' '{"method":"item/started","params":{"threadId":"thr-fixture","turnId":"turn-fixture","item":{"type":"agentMessage","id":"item-fixture","text":""}}}'
2112      printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr-fixture","turnId":"turn-fixture","itemId":"item-fixture","delta":"draft"}}'
2113      printf '%s\n' '{"method":"item/completed","params":{"threadId":"thr-fixture","turnId":"turn-fixture","item":{"type":"agentMessage","id":"item-fixture","text":"authoritative"}}}'
2114      printf '%s\n' '{"method":"thread/tokenUsage/updated","params":{"threadId":"thr-fixture","turnId":"turn-fixture","tokenUsage":{"last":{"cachedInputTokens":3,"inputTokens":21,"outputTokens":8,"reasoningOutputTokens":2,"totalTokens":29},"total":{"cachedInputTokens":103,"inputTokens":121,"outputTokens":108,"reasoningOutputTokens":102,"totalTokens":229},"modelContextWindow":128000}}}'
2115      printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thr-fixture","turn":{"id":"turn-fixture","status":"completed","items":[],"error":null}}}' ;;
2116  esac
2117done
2118"#;
2119
2120    #[cfg(unix)]
2121    const CONTROL_NOTIFICATION_DISCOVERY_SCRIPT: &str = r#"
2122while IFS= read -r line; do
2123  case "$line" in
2124    *'"method":"initialize"'*)
2125      printf '%s\n' '{"id":1,"result":{"userAgent":"steadymux/0.148.0"}}'
2126      printf '%s\n' '{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"private-server-canary","installationId":"private-installation-canary","environmentId":null},"emittedAtMs":1}' ;;
2127    *'"method":"initialized"'*) ;;
2128    *'"method":"account/read"'*) printf '%s\n' '{"id":2,"result":{"account":{"type":"chatgpt","planType":"pro"},"requiresOpenaiAuth":true}}' ;;
2129    *'"method":"model/list"'*) printf '%s\n' '{"id":3,"result":{"data":[],"nextCursor":null}}' ;;
2130    *'"method":"account/rateLimits/read"'*) printf '%s\n' '{"id":4,"result":{"rateLimits":{"primary":null,"secondary":null,"rateLimitReachedType":null}}}' ;;
2131  esac
2132done
2133"#;
2134
2135    #[cfg(unix)]
2136    const CONTROL_NOTIFICATION_FLOOD_SCRIPT: &str = r#"
2137while IFS= read -r line; do
2138  case "$line" in
2139    *'"method":"initialize"'*)
2140      printf '%s\n' '{"id":1,"result":{"userAgent":"steadymux/0.148.0"}}'
2141      ignored=0
2142      while [ "$ignored" -lt 65 ]; do
2143        printf '%s\n' '{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"private-server-canary","installationId":"private-installation-canary","environmentId":null},"emittedAtMs":1}'
2144        ignored=$((ignored + 1))
2145      done ;;
2146    *'"method":"initialized"'*) ;;
2147    *'"method":"account/read"'*) printf '%s\n' '{"id":2,"result":{"account":{"type":"chatgpt","planType":"pro"},"requiresOpenaiAuth":true}}' ;;
2148  esac
2149done
2150"#;
2151
2152    #[cfg(unix)]
2153    const CONTROL_NOTIFICATION_THRESHOLD_SCRIPT: &str = r#"
2154while IFS= read -r line; do
2155  case "$line" in
2156    *'"method":"initialize"'*)
2157      printf '%s\n' '{"id":1,"result":{"userAgent":"steadymux/0.148.0"}}'
2158      ignored=0
2159      while [ "$ignored" -lt 64 ]; do
2160        printf '%s\n' '{"method":"remoteControl/status/changed","params":{"status":"disabled","serverName":"private-server-canary","installationId":"private-installation-canary","environmentId":null},"emittedAtMs":1}'
2161        ignored=$((ignored + 1))
2162      done ;;
2163    *'"method":"initialized"'*) ;;
2164    *'"method":"account/read"'*) printf '%s\n' '{"id":2,"result":{"account":{"type":"chatgpt","planType":"pro"},"requiresOpenaiAuth":true}}' ;;
2165    *'"method":"model/list"'*) printf '%s\n' '{"id":3,"result":{"data":[],"nextCursor":null}}' ;;
2166    *'"method":"account/rateLimits/read"'*) printf '%s\n' '{"id":4,"result":{"rateLimits":{"primary":null,"secondary":null,"rateLimitReachedType":null}}}' ;;
2167  esac
2168done
2169"#;
2170
2171    #[cfg(unix)]
2172    const NON_SEMANTIC_NO_TOOLS_TURN_SCRIPT: &str = r#"
2173while IFS= read -r line; do
2174  case "$line" in
2175    *'"method":"initialize"'*) printf '%s\n' '{"id":1,"result":{"userAgent":"steadymux/0.148.0"}}' ;;
2176    *'"method":"initialized"'*) ;;
2177    *'"method":"account/read"'*) printf '%s\n' '{"id":2,"result":{"account":{"type":"chatgpt","planType":"pro"},"requiresOpenaiAuth":true}}' ;;
2178    *'"method":"model/list"'*) printf '%s\n' '{"id":3,"result":{"data":[{"id":"gpt-5.6-luna","displayName":"Luna","hidden":false,"supportedReasoningEfforts":[{"reasoningEffort":"low"}]}],"nextCursor":null}}' ;;
2179    *'"method":"account/rateLimits/read"'*) printf '%s\n' '{"id":4,"result":{"rateLimits":{"primary":null,"secondary":null,"rateLimitReachedType":null}}}' ;;
2180    *'"method":"thread/start"'*)
2181      printf '%s\n' '{"id":5,"result":{"thread":{"id":"thr-notification"}}}'
2182      printf '%s\n' '{"method":"thread/started","params":{"thread":{"id":"thr-notification","preview":"private-preview-canary","ephemeral":true,"modelProvider":"openai","createdAt":1,"updatedAt":1,"status":{"type":"idle"},"cwd":"/private-cwd-canary","cliVersion":"0.148.0","source":"appServer","sessionId":"private-session-canary","turns":[],"agentNickname":null,"agentRole":null,"canAcceptDirectInput":true,"extra":null,"forkedFromId":null,"gitInfo":null,"historyMode":"legacy","name":null,"parentThreadId":null,"path":null,"recencyAt":1,"section":null,"sectionEnteredAt":null,"threadSource":null}}}' ;;
2183    *'"method":"turn/start"'*)
2184      printf '%s\n' '{"id":6,"result":{"turn":{"id":"turn-notification","status":"inProgress","items":[],"error":null}}}'
2185      printf '%s\n' '{"method":"thread/status/changed","params":{"threadId":"thr-notification","status":{"type":"active","activeFlags":[]}}}'
2186      printf '%s\n' '{"method":"turn/started","params":{"threadId":"thr-notification","turn":{"id":"turn-notification","status":"inProgress","items":[],"error":null}}}'
2187      printf '%s\n' '{"method":"item/started","params":{"threadId":"thr-notification","turnId":"turn-notification","startedAtMs":1,"item":{"type":"reasoning","id":"reasoning-notification","summary":[],"content":[]}}}'
2188      printf '%s\n' '{"method":"item/reasoning/summaryPartAdded","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"reasoning-notification","summaryIndex":0}}'
2189      printf '%s\n' '{"method":"item/reasoning/summaryTextDelta","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"reasoning-notification","summaryIndex":0,"delta":"private-summary-canary"}}'
2190      printf '%s\n' '{"method":"item/reasoning/textDelta","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"reasoning-notification","contentIndex":0,"delta":"private-reasoning-canary"}}'
2191      printf '%s\n' '{"method":"thread/tokenUsage/updated","params":{"threadId":"thr-notification","turnId":"turn-notification","tokenUsage":{"last":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10},"total":{"cachedInputTokens":11,"inputTokens":12,"outputTokens":13,"reasoningOutputTokens":14,"totalTokens":50},"modelContextWindow":128000}}}'
2192      printf '%s\n' '{"method":"item/completed","params":{"threadId":"thr-notification","turnId":"turn-notification","completedAtMs":2,"item":{"type":"reasoning","id":"reasoning-notification","summary":[],"content":[]}}}'
2193      printf '%s\n' '{"method":"item/started","params":{"threadId":"thr-notification","turnId":"turn-notification","startedAtMs":3,"item":{"type":"agentMessage","id":"item-notification","text":""}}}'
2194      printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"item-notification","delta":"answer"}}'
2195      printf '%s\n' '{"method":"item/completed","params":{"threadId":"thr-notification","turnId":"turn-notification","completedAtMs":4,"item":{"type":"agentMessage","id":"item-notification","text":"answer"}}}'
2196      printf '%s\n' '{"method":"thread/status/changed","params":{"threadId":"thr-notification","status":{"type":"idle"}}}'
2197      printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thr-notification","turn":{"id":"turn-notification","status":"completed","items":[],"error":null}}}' ;;
2198  esac
2199done
2200"#;
2201
2202    #[cfg(unix)]
2203    const TOOL_SCRIPT: &str = r#"
2204while IFS= read -r line; do
2205  case "$line" in
2206    *'"method":"initialize"'*) printf '%s\n' '{"id":1,"result":{"userAgent":"steadymux/0.148.0"}}' ;;
2207    *'"method":"initialized"'*) ;;
2208    *'"method":"account/read"'*) printf '%s\n' '{"id":2,"result":{"account":{"type":"chatgpt","planType":"pro"},"requiresOpenaiAuth":true}}' ;;
2209    *'"method":"model/list"'*) printf '%s\n' '{"id":3,"result":{"data":[{"id":"gpt-5.6-terra","displayName":"Terra","hidden":false,"supportedReasoningEfforts":[{"reasoningEffort":"medium"}]}],"nextCursor":null}}' ;;
2210    *'"method":"account/rateLimits/read"'*) printf '%s\n' '{"id":4,"result":{"rateLimits":{"primary":null,"secondary":null,"rateLimitReachedType":null}}}' ;;
2211    *'"method":"thread/start"'*) printf '%s\n' '{"id":5,"result":{"thread":{"id":"thr-tool"}}}' ;;
2212    *'"method":"turn/start"'*)
2213      printf '%s\n' '{"id":6,"result":{"turn":{"id":"turn-tool","status":"inProgress","items":[],"error":null}}}'
2214      printf '%s\n' '{"method":"turn/started","params":{"threadId":"thr-tool","turn":{"id":"turn-tool","status":"inProgress","items":[],"error":null}}}'
2215      printf '%s\n' '{"method":"item/started","params":{"threadId":"thr-tool","turnId":"turn-tool","item":{"type":"mcpToolCall","id":"call-tool","server":"fixture-server","tool":"fixture-read","status":"inProgress","arguments":{"query":"private-argument"},"appContext":null,"pluginId":null,"result":null,"error":null,"durationMs":null}}}'
2216      printf '%s\n' '{"method":"item/completed","params":{"threadId":"thr-tool","turnId":"turn-tool","completedAtMs":1,"item":{"type":"mcpToolCall","id":"call-tool","server":"fixture-server","tool":"fixture-read","status":"completed","arguments":{"query":"private-argument"},"appContext":null,"pluginId":null,"result":{"content":[{"type":"text","text":"private-result"}]},"error":null,"durationMs":1}}}'
2217      printf '%s\n' '{"method":"item/started","params":{"threadId":"thr-tool","turnId":"turn-tool","item":{"type":"agentMessage","id":"item-tool-answer","text":""}}}'
2218      printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr-tool","turnId":"turn-tool","itemId":"item-tool-answer","delta":"draft"}}'
2219      printf '%s\n' '{"method":"item/completed","params":{"threadId":"thr-tool","turnId":"turn-tool","item":{"type":"agentMessage","id":"item-tool-answer","text":"authoritative"}}}'
2220      printf '%s\n' '{"method":"thread/tokenUsage/updated","params":{"threadId":"thr-tool","turnId":"turn-tool","tokenUsage":{"last":{"cachedInputTokens":5,"inputTokens":31,"outputTokens":4,"reasoningOutputTokens":1,"totalTokens":35},"total":{"cachedInputTokens":205,"inputTokens":231,"outputTokens":204,"reasoningOutputTokens":201,"totalTokens":435},"modelContextWindow":128000}}}'
2221      printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thr-tool","turn":{"id":"turn-tool","status":"completed","items":[],"error":null}}}' ;;
2222  esac
2223done
2224"#;
2225
2226    #[cfg(unix)]
2227    const INTERRUPT_SCRIPT: &str = r#"
2228while IFS= read -r line; do
2229  case "$line" in
2230    *'"method":"initialize"'*) printf '%s\n' '{"id":1,"result":{"userAgent":"steadymux/0.148.0"}}' ;;
2231    *'"method":"initialized"'*) ;;
2232    *'"method":"account/read"'*) printf '%s\n' '{"id":2,"result":{"account":{"type":"chatgpt","planType":"pro"},"requiresOpenaiAuth":true}}' ;;
2233    *'"method":"model/list"'*) printf '%s\n' '{"id":3,"result":{"data":[{"id":"gpt-5.6-terra","displayName":"Terra","hidden":false,"supportedReasoningEfforts":[{"reasoningEffort":"medium","description":"balanced"}]}],"nextCursor":null}}' ;;
2234    *'"method":"account/rateLimits/read"'*) printf '%s\n' '{"id":4,"result":{"rateLimits":{"primary":null,"secondary":null,"rateLimitReachedType":null}}}' ;;
2235    *'"method":"thread/start"'*) printf '%s\n' '{"id":5,"result":{"thread":{"id":"thr-interrupt"}}}' ;;
2236    *'"method":"turn/start"'*)
2237      printf '%s\n' '{"id":6,"result":{"turn":{"id":"turn-interrupt","status":"inProgress","items":[],"error":null}}}'
2238      printf '%s\n' '{"method":"turn/started","params":{"threadId":"thr-interrupt","turn":{"id":"turn-interrupt","status":"inProgress","items":[],"error":null}}}' ;;
2239    *'"method":"turn/interrupt"'*)
2240      printf '%s\n' '{"id":7,"result":{}}'
2241      printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thr-interrupt","turn":{"id":"turn-interrupt","status":"interrupted","items":[],"error":null}}}' ;;
2242  esac
2243done
2244"#;
2245
2246    #[cfg(unix)]
2247    const WAIT_FAILURE_SCRIPT: &str = r#"
2248while IFS= read -r line; do
2249  case "$line" in
2250    *'"method":"initialize"'*) printf '%s\n' '{"id":1,"result":{"userAgent":"steadymux/0.148.0"}}' ;;
2251    *'"method":"initialized"'*) ;;
2252    *'"method":"account/read"'*) printf '%s\n' '{"id":2,"result":{"account":{"type":"chatgpt","planType":"pro"},"requiresOpenaiAuth":true}}' ;;
2253    *'"method":"model/list"'*) printf '%s\n' '{"id":3,"result":{"data":[{"id":"gpt-5.6-terra","displayName":"Terra","hidden":false,"supportedReasoningEfforts":[{"reasoningEffort":"medium"}]}],"nextCursor":null}}' ;;
2254    *'"method":"account/rateLimits/read"'*) printf '%s\n' '{"id":4,"result":{"rateLimits":{"primary":null,"secondary":null,"rateLimitReachedType":null}}}' ;;
2255    *'"method":"thread/start"'*) printf '%s\n' '{"id":5,"result":{"thread":{"id":"thr-failure"}}}' ;;
2256    *'"method":"turn/start"'*)
2257      printf '%s\n' '{"id":6,"result":{"turn":{"id":"turn-failure","status":"inProgress","items":[],"error":null}}}'
2258      printf '%s\n' '{"method":"turn/started","params":{"threadId":"thr-failure","turn":{"id":"turn-failure","status":"inProgress","items":[],"error":null}}}'
2259      exit 0 ;;
2260  esac
2261done
2262"#;
2263
2264    #[cfg(unix)]
2265    const INTERRUPT_PROTOCOL_FAILURE_SCRIPT: &str = r#"
2266while IFS= read -r line; do
2267  case "$line" in
2268    *'"method":"initialize"'*) printf '%s\n' '{"id":1,"result":{"userAgent":"steadymux/0.148.0"}}' ;;
2269    *'"method":"initialized"'*) ;;
2270    *'"method":"account/read"'*) printf '%s\n' '{"id":2,"result":{"account":{"type":"chatgpt","planType":"pro"},"requiresOpenaiAuth":true}}' ;;
2271    *'"method":"model/list"'*) printf '%s\n' '{"id":3,"result":{"data":[{"id":"gpt-5.6-terra","displayName":"Terra","hidden":false,"supportedReasoningEfforts":[{"reasoningEffort":"medium"}]}],"nextCursor":null}}' ;;
2272    *'"method":"account/rateLimits/read"'*) printf '%s\n' '{"id":4,"result":{"rateLimits":{"primary":null,"secondary":null,"rateLimitReachedType":null}}}' ;;
2273    *'"method":"thread/start"'*) printf '%s\n' '{"id":5,"result":{"thread":{"id":"thr-failure"}}}' ;;
2274    *'"method":"turn/start"'*)
2275      printf '%s\n' '{"id":6,"result":{"turn":{"id":"turn-failure","status":"inProgress","items":[],"error":null}}}'
2276      printf '%s\n' '{"method":"turn/started","params":{"threadId":"thr-failure","turn":{"id":"turn-failure","status":"inProgress","items":[],"error":null}}}' ;;
2277    *'"method":"turn/interrupt"'*) printf '%s\n' '{"method":"unexpected","params":{"content":"private-unsupported-canary"}}' ;;
2278  esac
2279done
2280"#;
2281
2282    #[cfg(unix)]
2283    const SIDE_EFFECT_REJECTION_SCRIPT: &str = r#"
2284while IFS= read -r line; do
2285  case "$line" in
2286    *'"method":"initialize"'*) printf '%s\n' '{"id":1,"result":{"userAgent":"steadymux/0.148.0"}}' ;;
2287    *'"method":"initialized"'*) ;;
2288    *'"method":"account/read"'*) printf '%s\n' '{"id":2,"result":{"account":{"type":"chatgpt","planType":"pro"},"requiresOpenaiAuth":true}}' ;;
2289    *'"method":"model/list"'*) printf '%s\n' '{"id":3,"result":{"data":[{"id":"gpt-5.6-terra","displayName":"Terra","hidden":false,"supportedReasoningEfforts":[{"reasoningEffort":"medium"}]}],"nextCursor":null}}' ;;
2290    *'"method":"account/rateLimits/read"'*) printf '%s\n' '{"id":4,"result":{"rateLimits":{"primary":null,"secondary":null,"rateLimitReachedType":null}}}' ;;
2291    *'"method":"thread/start"'*) printf '%s\n' '{"id":5,"result":{"thread":{"id":"thr-side-effect"}}}' ;;
2292    *'"method":"turn/start"'*)
2293      printf '%s\n' '{"id":6,"result":{"turn":{"id":"turn-side-effect","status":"inProgress","items":[],"error":null}}}'
2294      printf '%s\n' '{"method":"turn/started","params":{"threadId":"thr-side-effect","turn":{"id":"turn-side-effect","status":"inProgress","items":[],"error":null}}}'
2295      printf '%s\n' '{"method":"item/started","params":{"threadId":"thr-side-effect","turnId":"turn-side-effect","item":{"type":"commandExecution","id":"item-side-effect","command":"private-command-canary"}}}' ;;
2296  esac
2297done
2298"#;
2299
2300    #[cfg(unix)]
2301    const TURN_START_JSON_RPC_REJECTION_SCRIPT: &str = r#"
2302while IFS= read -r line; do
2303  case "$line" in
2304    *'"method":"initialize"'*) printf '%s\n' '{"id":1,"result":{"userAgent":"steadymux/0.148.0"}}' ;;
2305    *'"method":"initialized"'*) ;;
2306    *'"method":"account/read"'*) printf '%s\n' '{"id":2,"result":{"account":{"type":"chatgpt","planType":"pro"},"requiresOpenaiAuth":true}}' ;;
2307    *'"method":"model/list"'*) printf '%s\n' '{"id":3,"result":{"data":[{"id":"gpt-5.6-terra","displayName":"Terra","hidden":false,"supportedReasoningEfforts":[{"reasoningEffort":"medium"}]}],"nextCursor":null}}' ;;
2308    *'"method":"account/rateLimits/read"'*) printf '%s\n' '{"id":4,"result":{"rateLimits":{"primary":null,"secondary":null,"rateLimitReachedType":null}}}' ;;
2309    *'"method":"thread/start"'*) printf '%s\n' '{"id":5,"result":{"thread":{"id":"thr-rejected"}}}' ;;
2310    *'"method":"turn/start"'*) printf '%s\n' '{"id":6,"error":{"code":-32603,"message":"private-json-rpc-message-canary","data":{"content":"private-json-rpc-data-canary"}}}' ;;
2311  esac
2312done
2313"#;
2314
2315    #[cfg(unix)]
2316    const TURN_STARTED_BEFORE_RESPONSE_SCRIPT: &str = r#"
2317while IFS= read -r line; do
2318  case "$line" in
2319    *'"method":"initialize"'*) printf '%s\n' '{"id":1,"result":{"userAgent":"steadymux/0.148.0"}}' ;;
2320    *'"method":"initialized"'*) ;;
2321    *'"method":"account/read"'*) printf '%s\n' '{"id":2,"result":{"account":{"type":"chatgpt","planType":"pro"},"requiresOpenaiAuth":true}}' ;;
2322    *'"method":"model/list"'*) printf '%s\n' '{"id":3,"result":{"data":[{"id":"gpt-5.6-terra","displayName":"Terra","hidden":false,"supportedReasoningEfforts":[{"reasoningEffort":"medium"}]}],"nextCursor":null}}' ;;
2323    *'"method":"account/rateLimits/read"'*) printf '%s\n' '{"id":4,"result":{"rateLimits":{"primary":null,"secondary":null,"rateLimitReachedType":null}}}' ;;
2324    *'"method":"thread/start"'*) printf '%s\n' '{"id":5,"result":{"thread":{"id":"thr-notification-first"}}}' ;;
2325    *'"method":"turn/start"'*)
2326      printf '%s\n' '{"method":"turn/started","params":{"threadId":"thr-notification-first","turn":{"id":"turn-notification-first","status":"inProgress","items":[],"error":null}}}'
2327      printf '%s\n' '{"method":"item/started","params":{"threadId":"thr-notification-first","turnId":"turn-notification-first","item":{"type":"agentMessage","id":"item-notification-first","text":""}}}'
2328      printf '%s\n' '{"id":6,"result":{"turn":{"id":"turn-notification-first","status":"inProgress","items":[],"error":null}}}'
2329      printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr-notification-first","turnId":"turn-notification-first","itemId":"item-notification-first","delta":"answer"}}'
2330      printf '%s\n' '{"method":"item/completed","params":{"threadId":"thr-notification-first","turnId":"turn-notification-first","item":{"type":"agentMessage","id":"item-notification-first","text":"answer"}}}'
2331      printf '%s\n' '{"method":"thread/tokenUsage/updated","params":{"threadId":"thr-notification-first","turnId":"turn-notification-first","tokenUsage":{"last":{"cachedInputTokens":0,"inputTokens":2,"outputTokens":1,"reasoningOutputTokens":0,"totalTokens":3},"total":{"cachedInputTokens":0,"inputTokens":2,"outputTokens":1,"reasoningOutputTokens":0,"totalTokens":3},"modelContextWindow":128000}}}'
2332      printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thr-notification-first","turn":{"id":"turn-notification-first","status":"completed","items":[],"error":null}}}' ;;
2333  esac
2334done
2335"#;
2336
2337    #[cfg(unix)]
2338    const TURN_STARTED_WITHOUT_RESPONSE_SCRIPT: &str = r#"
2339while IFS= read -r line; do
2340  case "$line" in
2341    *'"method":"initialize"'*) printf '%s\n' '{"id":1,"result":{"userAgent":"steadymux/0.148.0"}}' ;;
2342    *'"method":"initialized"'*) ;;
2343    *'"method":"account/read"'*) printf '%s\n' '{"id":2,"result":{"account":{"type":"chatgpt","planType":"pro"},"requiresOpenaiAuth":true}}' ;;
2344    *'"method":"model/list"'*) printf '%s\n' '{"id":3,"result":{"data":[{"id":"gpt-5.6-terra","displayName":"Terra","hidden":false,"supportedReasoningEfforts":[{"reasoningEffort":"medium"}]}],"nextCursor":null}}' ;;
2345    *'"method":"account/rateLimits/read"'*) printf '%s\n' '{"id":4,"result":{"rateLimits":{"primary":null,"secondary":null,"rateLimitReachedType":null}}}' ;;
2346    *'"method":"thread/start"'*) printf '%s\n' '{"id":5,"result":{"thread":{"id":"thr-notification-timeout"}}}' ;;
2347    *'"method":"turn/start"'*)
2348      printf '%s\n' '{"method":"turn/started","params":{"threadId":"thr-notification-timeout","turn":{"id":"turn-notification-timeout","status":"inProgress","items":[],"error":null}}}' ;;
2349  esac
2350done
2351"#;
2352
2353    #[cfg(unix)]
2354    const INTERRUPT_TIMEOUT_SCRIPT: &str = r#"
2355while IFS= read -r line; do
2356  case "$line" in
2357    *'"method":"initialize"'*) printf '%s\n' '{"id":1,"result":{"userAgent":"steadymux/0.148.0"}}' ;;
2358    *'"method":"initialized"'*) ;;
2359    *'"method":"account/read"'*) printf '%s\n' '{"id":2,"result":{"account":{"type":"chatgpt","planType":"pro"},"requiresOpenaiAuth":true}}' ;;
2360    *'"method":"model/list"'*) printf '%s\n' '{"id":3,"result":{"data":[{"id":"gpt-5.6-terra","displayName":"Terra","hidden":false,"supportedReasoningEfforts":[{"reasoningEffort":"medium"}]}],"nextCursor":null}}' ;;
2361    *'"method":"account/rateLimits/read"'*) printf '%s\n' '{"id":4,"result":{"rateLimits":{"primary":null,"secondary":null,"rateLimitReachedType":null}}}' ;;
2362    *'"method":"thread/start"'*) printf '%s\n' '{"id":5,"result":{"thread":{"id":"thr-timeout"}}}' ;;
2363    *'"method":"turn/start"'*)
2364      printf '%s\n' '{"id":6,"result":{"turn":{"id":"turn-timeout","status":"inProgress","items":[],"error":null}}}'
2365      printf '%s\n' '{"method":"turn/started","params":{"threadId":"thr-timeout","turn":{"id":"turn-timeout","status":"inProgress","items":[],"error":null}}}' ;;
2366    *'"method":"turn/interrupt"'*) ;;
2367  esac
2368done
2369"#;
2370
2371    /// Spawns an owned fixture turn. Spawn and every setup operation
2372    /// (initialize, discover, thread/start, turn/start) run under the generous
2373    /// setup window so wall-clock scheduling latency can never race a tight
2374    /// timeout. The caller-supplied timeout is applied to the owned client
2375    /// afterwards so that only the deliberately-failing post-write phase
2376    /// (the turn/interrupt wait in the timeout fixtures) sees it.
2377    #[cfg(unix)]
2378    fn start_owned_fixture_turn_with_timeout(
2379        script: &str,
2380        turn_phase_timeout: Duration,
2381    ) -> (
2382        TempDir,
2383        CodexAppServerClient<OwnedAppServerTransport>,
2384        ActiveCodexTurn,
2385    ) {
2386        let (directory, config) = shell_fixture(script, TIMEOUT_FIXTURE_SETUP_WINDOW);
2387        let mut client = CodexAppServerClient::spawn(&config).expect("spawn failure fixture");
2388        client.initialize().expect("initialize failure fixture");
2389        client.discover().expect("discover failure fixture");
2390        let turn = client
2391            .start_turn(
2392                "failure-message",
2393                "bounded fixture prompt",
2394                "gpt-5.6-terra",
2395                ReasoningEffort::Medium,
2396            )
2397            .expect("start failure fixture turn");
2398        client.operation_timeout = turn_phase_timeout;
2399        (directory, client, turn)
2400    }
2401
2402    #[cfg(unix)]
2403    fn start_owned_fixture_turn(
2404        script: &str,
2405    ) -> (
2406        TempDir,
2407        CodexAppServerClient<OwnedAppServerTransport>,
2408        ActiveCodexTurn,
2409    ) {
2410        start_owned_fixture_turn_with_timeout(script, Duration::from_secs(2))
2411    }
2412
2413    #[test]
2414    #[cfg(unix)]
2415    fn owned_fake_process_completes_discovery_and_authoritative_turn() {
2416        let (_directory, config) = shell_fixture(COMPLETE_SCRIPT, Duration::from_secs(2));
2417        let mut client = CodexAppServerClient::spawn(&config).expect("spawn owned fixture child");
2418        client.initialize().expect("initialize fixture client");
2419        let discovery = client.discover().expect("bounded discovery");
2420        assert_eq!(
2421            discovery.schema_version,
2422            APP_SERVER_DISCOVERY_SCHEMA_VERSION
2423        );
2424        assert_eq!(
2425            discovery.required_cli_version,
2426            CURRENT_STANDALONE_CLI_VERSION
2427        );
2428        assert_eq!(
2429            discovery.protocol_version,
2430            CODEX_APP_SERVER_PROTOCOL_VERSION
2431        );
2432        assert_eq!(discovery.account.plan_type.as_deref(), Some("pro"));
2433        assert_eq!(discovery.model_pages.len(), 1);
2434        assert_eq!(discovery.model_pages[0].models[0].id, "gpt-5.6-terra");
2435        let turn = client
2436            .start_turn(
2437                "fixture-message",
2438                "bounded fixture prompt",
2439                "gpt-5.6-terra",
2440                ReasoningEffort::Medium,
2441            )
2442            .expect("start exact selected turn");
2443        let receipt = client
2444            .wait_for_terminal(&turn)
2445            .expect("terminal completion");
2446        assert_eq!(receipt.completion.status, TerminalStatus::Completed);
2447        assert_eq!(receipt.completion.output.as_deref(), Some("authoritative"));
2448        assert_eq!(receipt.completion.input_tokens, Some(21));
2449        assert_eq!(receipt.completion.output_tokens, Some(8));
2450        assert!(client.shutdown().expect("shutdown fixture").success);
2451        assert!(!format!("{client:?}").contains("private@example.test"));
2452    }
2453
2454    #[test]
2455    #[cfg(unix)]
2456    fn desktop_version_guard_is_reported_from_verified_initialize_and_mismatch_refuses() {
2457        let desktop_script = COMPLETE_SCRIPT.replace(
2458            "\"userAgent\":\"steadymux/0.148.0\"",
2459            &format!("\"userAgent\":\"steadymux/{CURRENT_DESKTOP_CODEX_VERSION}\""),
2460        );
2461        let (_directory, config) = shell_fixture(&desktop_script, Duration::from_secs(2));
2462        let mut client = CodexAppServerClient::spawn(&config).expect("desktop transcript client");
2463        client
2464            .require_app_server_version(CURRENT_DESKTOP_CODEX_VERSION)
2465            .expect("closed desktop version guard");
2466        client.initialize().expect("verified desktop initialize");
2467        let discovery = client.discover().expect("desktop discovery");
2468        assert_eq!(
2469            discovery.required_cli_version,
2470            CURRENT_DESKTOP_CODEX_VERSION
2471        );
2472        assert!(client.shutdown().expect("desktop shutdown").success);
2473
2474        let (_directory, config) = shell_fixture(&desktop_script, Duration::from_secs(2));
2475        let mut mismatch =
2476            CodexAppServerClient::spawn(&config).expect("mismatch transcript client");
2477        mismatch
2478            .require_app_server_version(CURRENT_STANDALONE_CLI_VERSION)
2479            .expect("closed standalone version guard");
2480        assert_eq!(
2481            mismatch.initialize(),
2482            Err(AppServerProcessError::UnsupportedExecutableVersion)
2483        );
2484    }
2485
2486    #[test]
2487    #[cfg(unix)]
2488    fn owned_process_sends_exact_redacted_thread_instruction_shape() {
2489        let (_directory, config, trace) = instruction_trace_fixture();
2490        let mut client = CodexAppServerClient::spawn(&config).expect("spawn instruction fixture");
2491        client.initialize().expect("initialize instruction fixture");
2492        client.discover().expect("discover instruction fixture");
2493        let base = "private-base-process-canary\nsecond line";
2494        let developer = "private-developer-process-canary\nsecond line";
2495        let turn = client
2496            .start_turn_with_instructions(
2497                "instruction-message",
2498                "bounded fixture prompt",
2499                Some(base),
2500                Some(developer),
2501                "gpt-5.6-terra",
2502                ReasoningEffort::Medium,
2503            )
2504            .expect("start turn with bounded instructions");
2505        assert_eq!(turn.thread_id, "thr-instructions");
2506        assert_eq!(turn.turn_id, "turn-instructions");
2507
2508        let request: serde_json::Value = serde_json::from_str(
2509            fs::read_to_string(&trace)
2510                .expect("read exact thread request trace")
2511                .trim_end(),
2512        )
2513        .expect("parse exact thread request trace");
2514        assert_eq!(
2515            request["params"],
2516            serde_json::json!({
2517                "model": "gpt-5.6-terra",
2518                "modelProvider": "openai",
2519                "approvalPolicy": "never",
2520                "sandbox": "read-only",
2521                "ephemeral": true,
2522                "baseInstructions": base,
2523                "developerInstructions": developer
2524            })
2525        );
2526        let debug = format!("{client:?} {turn:?}");
2527        assert!(!debug.contains(base));
2528        assert!(!debug.contains(developer));
2529
2530        let secret = "sk-privateprocessinstruction123456";
2531        let error = client
2532            .start_turn_with_instructions(
2533                "rejected-instruction-message",
2534                "bounded fixture prompt",
2535                None,
2536                Some(secret),
2537                "gpt-5.6-terra",
2538                ReasoningEffort::Medium,
2539            )
2540            .expect_err("secret-shaped instruction must fail before transport write");
2541        assert_eq!(
2542            error,
2543            AppServerProcessError::Runtime(RuntimeError::SensitiveInputRejected)
2544        );
2545        assert!(!format!("{error:?} {error} {client:?}").contains(secret));
2546        assert_eq!(
2547            fs::read_to_string(&trace)
2548                .expect("thread trace remains a single request")
2549                .lines()
2550                .count(),
2551            1
2552        );
2553        assert!(
2554            client
2555                .shutdown()
2556                .expect("shutdown instruction fixture")
2557                .success
2558        );
2559    }
2560
2561    #[test]
2562    #[cfg(unix)]
2563    fn reviewed_control_notification_is_discarded_without_buffering() {
2564        let (_directory, config) = shell_fixture(
2565            CONTROL_NOTIFICATION_DISCOVERY_SCRIPT,
2566            Duration::from_secs(2),
2567        );
2568        let mut client = CodexAppServerClient::spawn(&config).expect("spawn control fixture");
2569        client.initialize().expect("initialize control fixture");
2570        let discovery = client
2571            .discover()
2572            .expect("control notification must not block discovery");
2573        assert!(
2574            discovery
2575                .model_pages
2576                .iter()
2577                .all(|page| page.models.is_empty())
2578        );
2579        assert!(client.buffered.is_empty());
2580        let debug = format!("{client:?}");
2581        assert!(!debug.contains("private-server-canary"));
2582        assert!(!debug.contains("private-installation-canary"));
2583        assert!(client.shutdown().expect("shutdown control fixture").success);
2584    }
2585
2586    #[test]
2587    #[cfg(unix)]
2588    fn ignored_control_notification_flood_is_bounded() {
2589        let (_directory, config) =
2590            shell_fixture(CONTROL_NOTIFICATION_FLOOD_SCRIPT, Duration::from_secs(2));
2591        let mut client = CodexAppServerClient::spawn(&config).expect("spawn flood fixture");
2592        client.initialize().expect("initialize flood fixture");
2593        assert_eq!(
2594            client.discover(),
2595            Err(AppServerProcessError::EventCapacityExceeded)
2596        );
2597        assert!(
2598            client
2599                .shutdown()
2600                .expect("flood child already reaped")
2601                .success
2602        );
2603    }
2604
2605    #[test]
2606    #[cfg(unix)]
2607    fn ignored_control_notification_exact_threshold_is_accepted() {
2608        let (_directory, config) = shell_fixture(
2609            CONTROL_NOTIFICATION_THRESHOLD_SCRIPT,
2610            Duration::from_secs(2),
2611        );
2612        let mut client = CodexAppServerClient::spawn(&config).expect("spawn threshold fixture");
2613        client.initialize().expect("initialize threshold fixture");
2614        client
2615            .discover()
2616            .expect("exactly 64 ignored notifications remain within the bound");
2617        assert!(client.buffered.is_empty());
2618        assert!(
2619            client
2620                .shutdown()
2621                .expect("shutdown threshold fixture")
2622                .success
2623        );
2624    }
2625
2626    #[test]
2627    #[cfg(unix)]
2628    fn no_tools_turn_discards_reviewed_non_semantic_notifications() {
2629        let (_directory, config) =
2630            shell_fixture(NON_SEMANTIC_NO_TOOLS_TURN_SCRIPT, Duration::from_secs(2));
2631        let mut client = CodexAppServerClient::spawn(&config).expect("spawn notification fixture");
2632        client
2633            .initialize()
2634            .expect("initialize notification fixture");
2635        client.discover().expect("discover notification fixture");
2636        let turn = client
2637            .start_turn(
2638                "notification-message",
2639                "bounded fixture prompt",
2640                "gpt-5.6-luna",
2641                ReasoningEffort::Low,
2642            )
2643            .expect("start notification fixture turn");
2644        let receipt = client
2645            .wait_for_terminal(&turn)
2646            .expect("reviewed notifications must not make the turn ambiguous");
2647        assert_eq!(receipt.completion.output.as_deref(), Some("answer"));
2648        assert_eq!(receipt.completion.input_tokens, Some(2));
2649        assert_eq!(receipt.completion.output_tokens, Some(3));
2650        assert_eq!(receipt.events.len(), 7);
2651        assert!(client.buffered.is_empty());
2652        let debug = format!("{receipt:?} {client:?}");
2653        assert!(!debug.contains("private-summary-canary"));
2654        assert!(!debug.contains("private-reasoning-canary"));
2655        assert!(
2656            client
2657                .shutdown()
2658                .expect("shutdown notification fixture")
2659                .success
2660        );
2661    }
2662
2663    #[test]
2664    #[cfg(unix)]
2665    fn owned_fake_process_preserves_canonical_tool_call_result_lifecycle() {
2666        let (_directory, config) = shell_fixture(TOOL_SCRIPT, Duration::from_secs(2));
2667        let mut client = CodexAppServerClient::spawn_with_tool_policy(
2668            &config,
2669            CodexToolPolicy::CanonicalMcpLifecycle,
2670        )
2671        .expect("spawn owned tool fixture child");
2672        client.initialize().unwrap();
2673        client.discover().unwrap();
2674        let turn = client
2675            .start_turn(
2676                "fixture-tool-message",
2677                "bounded fixture prompt",
2678                "gpt-5.6-terra",
2679                ReasoningEffort::Medium,
2680            )
2681            .unwrap();
2682        let receipt = client.wait_for_terminal(&turn).unwrap();
2683        assert!(matches!(
2684            receipt.events.as_slice(),
2685            [
2686                RuntimeEvent::TurnStarted { .. },
2687                RuntimeEvent::ToolCallStarted { item_id: started, .. },
2688                RuntimeEvent::ToolCallCompleted { item_id: completed, .. },
2689                RuntimeEvent::ItemStarted { .. },
2690                RuntimeEvent::ItemDelta { .. },
2691                RuntimeEvent::ItemCompleted { .. },
2692                RuntimeEvent::TurnCompleted(_),
2693            ] if started == "call-tool" && completed == "call-tool"
2694        ));
2695        assert_eq!(
2696            receipt.completion.replay,
2697            crate::ReplayDisposition::DeniedToolOrSideEffect
2698        );
2699        assert_eq!(receipt.completion.output.as_deref(), Some("authoritative"));
2700        assert_eq!(receipt.completion.input_tokens, Some(31));
2701        assert_eq!(receipt.completion.output_tokens, Some(4));
2702        assert!(!format!("{receipt:?}").contains("private-argument"));
2703        assert!(!format!("{receipt:?}").contains("private-result"));
2704        assert!(client.shutdown().unwrap().success);
2705    }
2706
2707    #[test]
2708    #[cfg(unix)]
2709    fn interrupt_requires_ack_and_terminal_interrupted_before_success() {
2710        let (_directory, config) = shell_fixture(INTERRUPT_SCRIPT, Duration::from_secs(2));
2711        let mut client = CodexAppServerClient::spawn(&config).expect("spawn interrupt fixture");
2712        client.initialize().unwrap();
2713        client.discover().unwrap();
2714        let turn = client
2715            .start_turn(
2716                "fixture-message",
2717                "bounded fixture prompt",
2718                "gpt-5.6-terra",
2719                ReasoningEffort::Medium,
2720            )
2721            .unwrap();
2722        let receipt = client.interrupt(&turn).expect("correlated interruption");
2723        assert_eq!(receipt.completion.status, TerminalStatus::Interrupted);
2724        assert_eq!(receipt.completion.input_tokens, None);
2725        assert_eq!(receipt.completion.output_tokens, None);
2726        assert!(client.shutdown().unwrap().success);
2727    }
2728
2729    #[test]
2730    #[cfg(unix)]
2731    fn wait_transport_failure_after_turn_write_is_explicitly_non_replayable() {
2732        let (_directory, mut client, turn) = start_owned_fixture_turn(WAIT_FAILURE_SCRIPT);
2733        assert_eq!(
2734            client.wait_for_terminal(&turn),
2735            Err(AppServerProcessError::PostWriteUnexpectedEofNoReplay)
2736        );
2737        assert!(
2738            client
2739                .shutdown()
2740                .expect("failed child already reaped")
2741                .success
2742        );
2743    }
2744
2745    #[test]
2746    #[cfg(unix)]
2747    fn unsupported_notification_after_turn_write_has_static_non_replayable_code() {
2748        let (_directory, mut client, turn) =
2749            start_owned_fixture_turn(INTERRUPT_PROTOCOL_FAILURE_SCRIPT);
2750        let error = client
2751            .interrupt(&turn)
2752            .expect_err("unsupported notification must fail closed without replay");
2753        assert_eq!(
2754            error,
2755            AppServerProcessError::PostWriteNotificationOrStateMismatchNoReplay
2756        );
2757        assert!(!format!("{error:?} {error}").contains("private-unsupported-canary"));
2758        assert!(
2759            client
2760                .shutdown()
2761                .expect("failed child already reaped")
2762                .success
2763        );
2764    }
2765
2766    #[test]
2767    #[cfg(unix)]
2768    fn side_effect_rejection_after_turn_write_has_static_content_free_code() {
2769        let (_directory, mut client, turn) = start_owned_fixture_turn(SIDE_EFFECT_REJECTION_SCRIPT);
2770        let error = client
2771            .wait_for_terminal(&turn)
2772            .expect_err("side-effecting item must fail closed without replay");
2773        assert_eq!(
2774            error,
2775            AppServerProcessError::PostWriteSideEffectRejectedNoReplay
2776        );
2777        let debug = format!("{error:?} {error} {client:?}");
2778        assert!(!debug.contains("private-command-canary"));
2779        assert!(
2780            client
2781                .shutdown()
2782                .expect("side-effect rejection child already reaped")
2783                .success
2784        );
2785    }
2786
2787    #[test]
2788    #[cfg(unix)]
2789    fn json_rpc_rejection_after_turn_write_has_static_content_free_code() {
2790        let (_directory, config) =
2791            shell_fixture(TURN_START_JSON_RPC_REJECTION_SCRIPT, Duration::from_secs(2));
2792        let mut client = CodexAppServerClient::spawn(&config).expect("spawn rejection fixture");
2793        client.initialize().expect("initialize rejection fixture");
2794        client.discover().expect("discover rejection fixture");
2795        let error = client
2796            .start_turn(
2797                "rejected-message",
2798                "bounded fixture prompt",
2799                "gpt-5.6-terra",
2800                ReasoningEffort::Medium,
2801            )
2802            .expect_err("JSON-RPC rejection must fail closed without replay");
2803        assert_eq!(
2804            error,
2805            AppServerProcessError::PostWriteJsonRpcRejectedNoReplay
2806        );
2807        let debug = format!("{error:?} {error} {client:?}");
2808        assert!(!debug.contains("private-json-rpc-message-canary"));
2809        assert!(!debug.contains("private-json-rpc-data-canary"));
2810        assert!(
2811            client
2812                .shutdown()
2813                .expect("rejected child already reaped")
2814                .success
2815        );
2816    }
2817
2818    #[test]
2819    #[cfg(unix)]
2820    fn turn_scoped_notification_before_turn_start_response_completes() {
2821        let (_directory, config) =
2822            shell_fixture(TURN_STARTED_BEFORE_RESPONSE_SCRIPT, Duration::from_secs(2));
2823        let mut client = CodexAppServerClient::spawn(&config).expect("spawn ordering fixture");
2824        client.initialize().expect("initialize ordering fixture");
2825        client.discover().expect("discover ordering fixture");
2826        let turn = client
2827            .start_turn(
2828                "ordering-message",
2829                "bounded fixture prompt",
2830                "gpt-5.6-terra",
2831                ReasoningEffort::Medium,
2832            )
2833            .expect("exactly correlated notification-first turn");
2834        let receipt = client
2835            .wait_for_terminal(&turn)
2836            .expect("notification-first turn must complete");
2837        assert_eq!(receipt.completion.output.as_deref(), Some("answer"));
2838        assert_eq!(receipt.completion.input_tokens, Some(2));
2839        assert_eq!(receipt.completion.output_tokens, Some(1));
2840        assert!(
2841            client
2842                .shutdown()
2843                .expect("ordering failure child already reaped")
2844                .success
2845        );
2846    }
2847
2848    #[test]
2849    #[cfg(unix)]
2850    fn notification_first_missing_response_times_out_without_replay() {
2851        let (_directory, config) = shell_fixture(
2852            TURN_STARTED_WITHOUT_RESPONSE_SCRIPT,
2853            TIMEOUT_FIXTURE_SETUP_WINDOW,
2854        );
2855        let mut client = CodexAppServerClient::spawn(&config).expect("spawn timeout fixture");
2856        client.initialize().expect("initialize timeout fixture");
2857        client.discover().expect("discover timeout fixture");
2858        // thread/start runs under the generous setup window; the tight timeout
2859        // is clamped on only for the turn/start wait, whose terminal state is
2860        // a guaranteed timeout (the fixture never responds and never exits).
2861        client.turn_operation_timeout = Some(Duration::from_millis(50));
2862        assert_eq!(
2863            client.start_turn(
2864                "ordering-timeout-message",
2865                "bounded fixture prompt",
2866                "gpt-5.6-terra",
2867                ReasoningEffort::Medium,
2868            ),
2869            Err(AppServerProcessError::PostWriteTimeoutNoReplay)
2870        );
2871        assert!(
2872            client
2873                .shutdown()
2874                .expect("timed out child already reaped")
2875                .success
2876        );
2877    }
2878
2879    #[test]
2880    #[cfg(unix)]
2881    fn timeout_after_turn_write_is_distinct_from_eof_and_non_replayable() {
2882        let (_directory, mut client, turn) = start_owned_fixture_turn_with_timeout(
2883            INTERRUPT_TIMEOUT_SCRIPT,
2884            Duration::from_millis(50),
2885        );
2886        assert_eq!(
2887            client.interrupt(&turn),
2888            Err(AppServerProcessError::PostWriteTimeoutNoReplay)
2889        );
2890        assert!(
2891            client
2892                .shutdown()
2893                .expect("timed out child already reaped")
2894                .success
2895        );
2896    }
2897
2898    #[test]
2899    #[cfg(unix)]
2900    fn timeout_forces_owned_child_reap() {
2901        let sleep = std::path::Path::new("/bin/sleep")
2902            .canonicalize()
2903            .expect("canonical sleep fixture");
2904        let config = OwnedAppServerConfig::fixture(
2905            &sleep,
2906            vec![OsString::from("60")],
2907            Duration::from_millis(25),
2908        );
2909        let mut client = CodexAppServerClient::spawn(&config).expect("spawn stalled fixture child");
2910        assert!(client.transport.child_id().is_some());
2911        assert_eq!(client.initialize(), Err(AppServerProcessError::Timeout));
2912        assert!(client.transport.child_id().is_none());
2913        let receipt = client
2914            .shutdown()
2915            .expect("timeout path already reaped child");
2916        assert!(receipt.success);
2917    }
2918
2919    #[test]
2920    #[cfg(unix)]
2921    fn production_owned_child_constructor_disables_hooks_shell_and_web_search() {
2922        let (directory, executable, _trace) = attested_executable_fixture("0.145.0");
2923        let codex_home = mcp_config_home(
2924            &directory,
2925            &[
2926                "[mcp_servers.zeta]",
2927                "command = \"private-command\"",
2928                "[mcp_servers.zeta.env]",
2929                "PRIVATE_ENV = \"private-value\"",
2930                "[mcp_servers.alpha]",
2931            ],
2932        );
2933        let config = OwnedAppServerConfig::codex_with_mcp_server_isolation(
2934            executable,
2935            codex_home,
2936            Duration::from_secs(5),
2937            Duration::from_secs(1),
2938        )
2939        .expect("synthetic owned-child configuration");
2940        let args = config
2941            .args
2942            .iter()
2943            .map(|argument| argument.to_string_lossy().into_owned())
2944            .collect::<Vec<_>>();
2945
2946        assert_eq!(
2947            args,
2948            vec![
2949                "app-server",
2950                "--listen",
2951                "stdio://",
2952                "-c",
2953                "model_provider=\"openai\"",
2954                "-c",
2955                "web_search=\"disabled\"",
2956                "--disable",
2957                "hooks",
2958                "--disable",
2959                "apps",
2960                "--disable",
2961                "multi_agent",
2962                "--disable",
2963                "shell_tool",
2964                "--disable",
2965                "unified_exec",
2966                "--disable",
2967                "standalone_web_search",
2968                "-c",
2969                "mcp_servers.alpha.enabled=false",
2970                "-c",
2971                "mcp_servers.zeta.enabled=false",
2972            ],
2973            "production owned children must disable apps, multi-agent tools, and every configured MCP server as well as hooks, stable shell executors, and standalone web search"
2974        );
2975        let attestation = config
2976            .mcp_isolation_attestation()
2977            .expect("production Codex config is MCP-isolated");
2978        assert_eq!(attestation.configured_server_count, 2);
2979        assert!(valid_sha256(&attestation.config_sha256));
2980        assert!(valid_sha256(&attestation.override_set_sha256));
2981        assert!(!format!("{config:?}").contains("alpha"));
2982    }
2983
2984    #[test]
2985    #[cfg(unix)]
2986    fn mcp_server_toml_representations_produce_exact_disable_overrides() {
2987        let cases = [
2988            (
2989                "mcp_servers.dotted = { command = \"private-command\" }",
2990                "dotted",
2991            ),
2992            (
2993                "mcp_servers = { inline = { command = \"private-command\" } }",
2994                "inline",
2995            ),
2996            (
2997                "[ mcp_servers . whitespace ]\ncommand = \"private-command\"",
2998                "whitespace",
2999            ),
3000        ];
3001        for (toml, expected_key) in cases {
3002            let (directory, executable, _trace) = attested_executable_fixture("0.145.0");
3003            let codex_home = mcp_config_home(&directory, &[toml]);
3004            let config = OwnedAppServerConfig::codex_with_mcp_server_isolation(
3005                executable,
3006                codex_home,
3007                Duration::from_secs(1),
3008                Duration::from_secs(1),
3009            )
3010            .expect("TOML-equivalent direct MCP server table");
3011            let overrides = config
3012                .args
3013                .windows(2)
3014                .filter(|arguments| arguments[0] == "-c")
3015                .map(|arguments| arguments[1].to_string_lossy().into_owned())
3016                .filter(|argument| argument.starts_with("mcp_servers."))
3017                .collect::<Vec<_>>();
3018            assert_eq!(
3019                overrides,
3020                vec![format!("mcp_servers.{expected_key}.enabled=false")],
3021                "every supported TOML representation must produce one exact configured-server disable"
3022            );
3023        }
3024    }
3025
3026    #[test]
3027    #[cfg(unix)]
3028    fn configured_codex_server_is_disabled_once_without_synthetic_builtin_override() {
3029        let (directory, executable, _trace) = attested_executable_fixture("0.145.0");
3030        let codex_home = mcp_config_home(&directory, &["[mcp_servers.codex]"]);
3031        let config = OwnedAppServerConfig::codex_with_mcp_server_isolation(
3032            executable,
3033            codex_home,
3034            Duration::from_secs(1),
3035            Duration::from_secs(1),
3036        )
3037        .expect("managed Codex server configuration");
3038        let overrides = config
3039            .args
3040            .windows(2)
3041            .filter(|arguments| arguments[0] == "-c")
3042            .map(|arguments| arguments[1].to_string_lossy().into_owned())
3043            .filter(|argument| argument.starts_with("mcp_servers."))
3044            .collect::<Vec<_>>();
3045        assert_eq!(overrides, vec!["mcp_servers.codex.enabled=false"]);
3046        assert_eq!(
3047            config
3048                .mcp_isolation_attestation()
3049                .expect("production child has isolation attestation")
3050                .configured_server_count,
3051            1,
3052            "the attestation reports the configured Codex server exactly once"
3053        );
3054    }
3055
3056    #[test]
3057    #[cfg(unix)]
3058    fn maximum_configured_mcp_servers_and_builtin_bridge_fit_the_process_arg_cap() {
3059        let (directory, executable, _trace) = attested_executable_fixture("0.145.0");
3060        let tables = (0..MAX_MCP_SERVER_KEYS)
3061            .map(|index| format!("[mcp_servers.s{index:02}]"))
3062            .collect::<Vec<_>>();
3063        let table_refs = tables.iter().map(String::as_str).collect::<Vec<_>>();
3064        let codex_home = mcp_config_home(&directory, &table_refs);
3065        let config = OwnedAppServerConfig::codex_with_mcp_server_isolation(
3066            executable,
3067            codex_home,
3068            Duration::from_secs(1),
3069            Duration::from_secs(1),
3070        )
3071        .expect("the maximum configured MCP server count must remain spawnable");
3072        let overrides = config
3073            .args
3074            .windows(2)
3075            .filter(|arguments| arguments[0] == "-c")
3076            .map(|arguments| arguments[1].to_string_lossy().into_owned())
3077            .filter(|argument| argument.starts_with("mcp_servers."))
3078            .collect::<Vec<_>>();
3079        assert_eq!(
3080            overrides.len(),
3081            MAX_MCP_SERVER_KEYS,
3082            "all 32 configured servers must receive exact disable overrides"
3083        );
3084        assert_eq!(config.args.len(), MAX_PROCESS_ARGS);
3085        let attestation = config
3086            .mcp_isolation_attestation()
3087            .expect("production child has MCP isolation attestation");
3088        assert_eq!(attestation.configured_server_count, MAX_MCP_SERVER_KEYS);
3089        assert_eq!(
3090            attestation.override_set_sha256,
3091            "5a08526fa80dc6f3652a8684200dcc87f62b1038033b9c90fd916eff07db1f72",
3092            "the complete sorted configured-server disable set is committed"
3093        );
3094    }
3095
3096    #[test]
3097    #[cfg(unix)]
3098    fn more_than_maximum_configured_mcp_servers_refuse_before_process_construction() {
3099        let (directory, executable, _trace) = attested_executable_fixture("0.145.0");
3100        let tables = (0..=MAX_MCP_SERVER_KEYS)
3101            .map(|index| format!("[mcp_servers.s{index:02}]"))
3102            .collect::<Vec<_>>();
3103        let table_refs = tables.iter().map(String::as_str).collect::<Vec<_>>();
3104        let codex_home = mcp_config_home(&directory, &table_refs);
3105        assert!(
3106            matches!(
3107                OwnedAppServerConfig::codex_with_mcp_server_isolation(
3108                    executable,
3109                    codex_home,
3110                    Duration::from_secs(1),
3111                    Duration::from_secs(1),
3112                ),
3113                Err(AppServerProcessError::PreflightConfiguration(
3114                    PreflightConfigurationRefusal::ConfigArgCap,
3115                ))
3116            ),
3117            "a 33rd configured MCP server must refuse before child argv construction"
3118        );
3119    }
3120
3121    #[test]
3122    fn mcp_server_toml_non_table_or_unsafe_key_refuses() {
3123        for bytes in [
3124            b"mcp_servers = { scalar = \"private-command\" }".as_slice(),
3125            b"[mcp_servers.\"unsafe.key\"]\ncommand = \"private-command\"".as_slice(),
3126            b"mcp_servers = \"not-a-table\"".as_slice(),
3127        ] {
3128            assert_eq!(
3129                parse_mcp_server_keys(bytes),
3130                Err(AppServerProcessError::PreflightConfiguration(
3131                    PreflightConfigurationRefusal::ConfigSemanticKeys,
3132                )),
3133                "non-table and unsafe MCP server representations cannot reach child argv"
3134            );
3135        }
3136    }
3137
3138    #[test]
3139    #[cfg(unix)]
3140    fn mcp_server_isolation_allows_unrelated_private_codex_home_state_after_snapshot() {
3141        use std::os::unix::fs::PermissionsExt as _;
3142
3143        let (directory, executable, trace) = attested_executable_fixture("0.145.0");
3144        let codex_home = mcp_config_home(&directory, &["[mcp_servers.approved]"]);
3145        let qualification_state = codex_home.join("qualification-state.json");
3146        fs::write(
3147            &qualification_state,
3148            b"initial content-free qualification state",
3149        )
3150        .expect("write initial unrelated qualification state");
3151        let mut permissions = fs::metadata(&qualification_state)
3152            .expect("qualification state metadata")
3153            .permissions();
3154        permissions.set_mode(0o600);
3155        fs::set_permissions(&qualification_state, permissions)
3156            .expect("qualification state remains owner-private");
3157        let config = OwnedAppServerConfig::codex_with_mcp_server_isolation(
3158            executable,
3159            codex_home.clone(),
3160            Duration::from_secs(1),
3161            Duration::from_secs(1),
3162        )
3163        .expect("isolated config snapshot");
3164
3165        // A zero-turn qualification may write unrelated owner-private state
3166        // under CODEX_HOME.  That changes the directory timestamp but must not
3167        // weaken the separately full-pinned config.toml boundary.
3168        fs::write(
3169            &qualification_state,
3170            b"refreshed content-free qualification state",
3171        )
3172        .expect("write unrelated qualification state");
3173        // A qualification may also create and later remove an owner-private
3174        // subdirectory.  That changes the directory link count while leaving
3175        // its pinned structural identity and config.toml untouched.
3176        let qualification_directory = codex_home.join("qualification-cache");
3177        fs::create_dir(&qualification_directory).expect("create unrelated private directory");
3178        fs::remove_dir(&qualification_directory).expect("remove unrelated private directory");
3179
3180        let original_isolation = config
3181            .mcp_isolation
3182            .as_ref()
3183            .expect("production config keeps the original isolation snapshot");
3184        let refreshed_isolation = McpServerIsolation::read(codex_home.clone())
3185            .expect("unrelated state must leave the pinned config readable");
3186        assert_eq!(
3187            refreshed_isolation.codex_home_identity, original_isolation.codex_home_identity,
3188            "unrelated entries may alter directory timestamps but never structural identity"
3189        );
3190        assert_eq!(
3191            refreshed_isolation.config_identity, original_isolation.config_identity,
3192            "config.toml retains its complete file identity"
3193        );
3194        assert_eq!(
3195            refreshed_isolation.config_sha256, original_isolation.config_sha256,
3196            "config.toml retains its exact content binding"
3197        );
3198
3199        let mut transport = OwnedAppServerTransport::spawn(&config)
3200            .expect("unrelated CODEX_HOME state must not invalidate the pinned config");
3201        // The owned child writes its argv trace only after exec completes.  On
3202        // a heavily loaded machine the exec may be delayed well past the
3203        // shutdown timeout, so poll-wait for the trace with a generous
3204        // deadline and assert it exists before shutting down.  The shutdown
3205        // assertion stays separate so a load-induced exec delay can never be
3206        // mistaken for a failed revalidation.
3207        let trace_deadline = Instant::now() + Duration::from_secs(10);
3208        while !trace.exists() && Instant::now() < trace_deadline {
3209            thread::sleep(Duration::from_millis(5));
3210        }
3211        assert!(
3212            trace.exists(),
3213            "the revalidated configuration must reach the owned-child spawn"
3214        );
3215        assert!(
3216            transport
3217                .shutdown()
3218                .expect("owned fixture must shut down after the spawn proof")
3219                .success
3220        );
3221    }
3222
3223    #[test]
3224    #[cfg(unix)]
3225    fn mcp_isolation_revalidation_uses_fixed_drift_classes() {
3226        let (directory, _executable, _trace) = attested_executable_fixture("0.145.0");
3227        let codex_home = mcp_config_home(&directory, &["[mcp_servers.approved]"]);
3228
3229        let missing_home = directory.path().join("missing-codex-home");
3230        assert!(matches!(
3231            McpServerIsolation::read(missing_home),
3232            Err(AppServerProcessError::PreflightConfiguration(
3233                PreflightConfigurationRefusal::CodexHomeStructural,
3234            ))
3235        ));
3236
3237        use std::os::unix::fs::PermissionsExt as _;
3238        let blocked_directory = TempDir::new().expect("blocked MCP fixture directory");
3239        let blocked_config_home = mcp_config_home(&blocked_directory, &["[mcp_servers.blocked]"]);
3240        fs::set_permissions(
3241            blocked_config_home.join("config.toml"),
3242            fs::Permissions::from_mode(0o000),
3243        )
3244        .expect("block config open");
3245        assert!(matches!(
3246            McpServerIsolation::read(blocked_config_home),
3247            Err(AppServerProcessError::PreflightConfiguration(
3248                PreflightConfigurationRefusal::ConfigOpenTypeOwnerModeLink,
3249            ))
3250        ));
3251
3252        let mut file_identity = McpServerIsolation::read(codex_home.clone()).expect("snapshot");
3253        file_identity.config_identity.inode = file_identity.config_identity.inode.saturating_add(1);
3254        assert_eq!(
3255            file_identity
3256                .revalidate()
3257                .expect_err("identity drift is refused")
3258                .preflight_refusal_class(),
3259            "config-file-identity"
3260        );
3261
3262        let mut content = McpServerIsolation::read(codex_home.clone()).expect("snapshot");
3263        content.config_sha256 = "0".repeat(64);
3264        assert_eq!(
3265            content
3266                .revalidate()
3267                .expect_err("digest drift is refused")
3268                .preflight_refusal_class(),
3269            "config-content-digest"
3270        );
3271
3272        let mut semantic = McpServerIsolation::read(codex_home.clone()).expect("snapshot");
3273        semantic.server_keys.push("additional".to_owned());
3274        assert_eq!(
3275            semantic
3276                .revalidate()
3277                .expect_err("semantic drift is refused")
3278                .preflight_refusal_class(),
3279            "config-semantic-keys"
3280        );
3281
3282        let mut override_digest = McpServerIsolation::read(codex_home).expect("snapshot");
3283        override_digest.override_set_sha256 = "0".repeat(64);
3284        assert_eq!(
3285            override_digest
3286                .revalidate()
3287                .expect_err("override drift is refused")
3288                .preflight_refusal_class(),
3289            "config-override-digest"
3290        );
3291    }
3292
3293    #[test]
3294    #[cfg(unix)]
3295    fn mcp_server_config_substitution_or_unsafe_key_refuses_before_spawn() {
3296        use std::os::unix::fs::PermissionsExt as _;
3297
3298        let (directory, executable, trace) = attested_executable_fixture("0.145.0");
3299        let codex_home = mcp_config_home(&directory, &["[mcp_servers.approved]"]);
3300        let config = OwnedAppServerConfig::codex_with_mcp_server_isolation(
3301            executable,
3302            codex_home.clone(),
3303            Duration::from_secs(1),
3304            Duration::from_secs(1),
3305        )
3306        .expect("isolated config");
3307        fs::write(
3308            codex_home.join("config.toml"),
3309            "[mcp_servers.approved]\n[mcp_servers.uncovered]",
3310        )
3311        .expect("substitute config after constructor pin");
3312        let mut permissions = fs::metadata(codex_home.join("config.toml"))
3313            .expect("substituted config metadata")
3314            .permissions();
3315        permissions.set_mode(0o600);
3316        fs::set_permissions(codex_home.join("config.toml"), permissions)
3317            .expect("restore config mode");
3318        assert!(
3319            matches!(
3320                OwnedAppServerTransport::spawn(&config),
3321                Err(AppServerProcessError::PreflightConfiguration(
3322                    PreflightConfigurationRefusal::ConfigFileIdentity,
3323                ))
3324            ),
3325            "an uncovered server added after construction must prevent child spawn"
3326        );
3327        assert!(
3328            !trace.exists(),
3329            "configuration drift must be rejected before the owned child receives argv"
3330        );
3331
3332        let unsafe_directory = TempDir::new().expect("unsafe MCP fixture directory");
3333        let unsafe_home = mcp_config_home(&unsafe_directory, &["[mcp_servers.\"unsafe.key\"]"]);
3334        let error = OwnedAppServerConfig::codex_with_mcp_server_isolation(
3335            directory.path().join("codex-fixture"),
3336            unsafe_home,
3337            Duration::from_secs(1),
3338            Duration::from_secs(1),
3339        )
3340        .expect_err("quoted or dotted MCP keys cannot become child command arguments");
3341        assert_eq!(
3342            error,
3343            AppServerProcessError::PreflightConfiguration(
3344                PreflightConfigurationRefusal::ConfigSemanticKeys,
3345            )
3346        );
3347    }
3348
3349    #[test]
3350    #[cfg(unix)]
3351    fn mcp_server_config_replacement_refuses_before_spawn() {
3352        use std::os::unix::fs::PermissionsExt as _;
3353
3354        let (directory, executable, trace) = attested_executable_fixture("0.145.0");
3355        let codex_home = mcp_config_home(&directory, &["[mcp_servers.approved]"]);
3356        let config = OwnedAppServerConfig::codex_with_mcp_server_isolation(
3357            executable,
3358            codex_home.clone(),
3359            Duration::from_secs(1),
3360            Duration::from_secs(1),
3361        )
3362        .expect("isolated config");
3363
3364        let replacement = codex_home.join("replacement.toml");
3365        fs::write(&replacement, "[mcp_servers.approved]").expect("replacement config");
3366        let mut permissions = fs::metadata(&replacement)
3367            .expect("replacement metadata")
3368            .permissions();
3369        permissions.set_mode(0o600);
3370        fs::set_permissions(&replacement, permissions).expect("replacement private mode");
3371        fs::rename(&replacement, codex_home.join("config.toml"))
3372            .expect("atomically replace pinned config");
3373
3374        assert!(matches!(
3375            OwnedAppServerTransport::spawn(&config),
3376            Err(AppServerProcessError::PreflightConfiguration(
3377                PreflightConfigurationRefusal::ConfigFileIdentity,
3378            ))
3379        ));
3380        assert!(
3381            !trace.exists(),
3382            "a replacement config inode must be rejected before child spawn"
3383        );
3384    }
3385
3386    #[test]
3387    #[cfg(unix)]
3388    fn mismatched_owned_child_attestation_blocks_discovery_and_turns() {
3389        let (directory, executable, trace) = attested_executable_fixture("0.145.0");
3390        let codex_home = mcp_config_home(&directory, &["[mcp_servers.attested]"]);
3391        // Generous operation and shutdown windows: the fixture responds with a
3392        // version mismatch the moment it execs, so under heavy parallel load a
3393        // delayed child exec must time out with `UnsupportedExecutableVersion`
3394        // (the semantic under test) rather than a transport timeout.
3395        let config = OwnedAppServerConfig::codex_with_mcp_server_isolation(
3396            executable,
3397            codex_home,
3398            Duration::from_secs(30),
3399            Duration::from_secs(30),
3400        )
3401        .expect("synthetic owned-child configuration");
3402        let mut client = CodexAppServerClient::spawn(&config).expect("spawn exact owned child");
3403        assert_eq!(
3404            client
3405                .transport
3406                .mcp_isolation_attestation()
3407                .expect("spawn records revalidated MCP isolation")
3408                .configured_server_count,
3409            1
3410        );
3411        let error = client
3412            .initialize()
3413            .expect_err("mismatched same-child attestation must fail closed");
3414        assert_eq!(error, AppServerProcessError::UnsupportedExecutableVersion);
3415        let trace = fs::read_to_string(trace).expect("owned-child invocation trace");
3416        assert!(trace.contains("argv:app-server --listen stdio://"));
3417        assert!(
3418            trace.contains("model_provider=\"openai\""),
3419            "owned child must pin the OpenAI provider for managed-account discovery"
3420        );
3421        assert!(
3422            trace.contains("--disable hooks"),
3423            "owned child must disable hooks before managed-account execution"
3424        );
3425        assert!(
3426            trace.contains("--disable apps"),
3427            "owned child must disable the stable Apps capability before managed-account execution"
3428        );
3429        assert!(
3430            trace.contains("--disable multi_agent"),
3431            "owned child must disable the stable multi-agent capability before managed-account execution"
3432        );
3433        assert!(
3434            trace.contains("--disable shell_tool"),
3435            "owned child must disable the legacy shell executor"
3436        );
3437        assert!(
3438            trace.contains("--disable unified_exec"),
3439            "owned child must disable the current shell executor"
3440        );
3441        assert!(
3442            trace.contains("-c web_search=\"disabled\"")
3443                && trace.contains("--disable standalone_web_search"),
3444            "owned child must pin configuration and capability shutdown for web search"
3445        );
3446        assert!(
3447            trace.contains("-c mcp_servers.attested.enabled=false"),
3448            "the actual child argv must disable every server in the pinned MCP config"
3449        );
3450        assert!(trace.contains("\"method\":\"initialize\""));
3451        assert!(
3452            !trace.contains("\"method\":\"initialized\"")
3453                && !trace.contains("\"method\":\"model/list\"")
3454                && !trace.contains("\"method\":\"thread/start\"")
3455                && !trace.contains("\"method\":\"turn/start\"")
3456        );
3457        assert!(
3458            client
3459                .shutdown()
3460                .expect("mismatch child already reaped")
3461                .success
3462        );
3463    }
3464
3465    #[test]
3466    #[cfg(unix)]
3467    fn pinned_executable_substitution_refuses_before_spawn() {
3468        use std::os::unix::fs::PermissionsExt as _;
3469        let directory = TempDir::new().expect("temporary owned-child directory");
3470        let codex_home = mcp_config_home(&directory, &["[mcp_servers.approved]"]);
3471        let executable = directory.path().join("codex-app-server");
3472        fs::write(&executable, "#!/bin/sh\nexit 0\n").expect("fixture executable");
3473        let mut permissions = fs::metadata(&executable)
3474            .expect("fixture metadata")
3475            .permissions();
3476        permissions.set_mode(0o700);
3477        fs::set_permissions(&executable, permissions).expect("private executable mode");
3478        let expected = sha256_file(&executable).expect("fixture digest");
3479        let config = OwnedAppServerConfig::codex_pinned_with_mcp_server_isolation(
3480            executable.clone(),
3481            &expected,
3482            codex_home.clone(),
3483            Duration::from_secs(1),
3484            Duration::from_secs(1),
3485        )
3486        .expect("pinned configuration");
3487        let desktop_config = OwnedAppServerConfig::codex_desktop_pinned_with_mcp_server_isolation(
3488            executable.clone(),
3489            &expected,
3490            codex_home,
3491            Duration::from_secs(1),
3492            Duration::from_secs(1),
3493        )
3494        .expect("pinned desktop configuration");
3495        let replacement = directory.path().join("replacement");
3496        fs::write(&replacement, "#!/bin/sh\nexit 1\n").expect("replacement executable");
3497        let mut replacement_permissions = fs::metadata(&replacement)
3498            .expect("replacement metadata")
3499            .permissions();
3500        replacement_permissions.set_mode(0o700);
3501        fs::set_permissions(&replacement, replacement_permissions).expect("replacement mode");
3502        fs::rename(&replacement, &executable).expect("atomic substitution");
3503        assert!(matches!(
3504            OwnedAppServerTransport::spawn(&config),
3505            Err(AppServerProcessError::InvalidConfiguration)
3506        ));
3507        assert!(matches!(
3508            OwnedAppServerTransport::spawn(&desktop_config),
3509            Err(AppServerProcessError::InvalidConfiguration)
3510        ));
3511    }
3512
3513    /// Read-only compatibility probe for the explicitly reviewed Codex CLI.
3514    /// This test never starts a thread or turn and is both ignored and gated by
3515    /// an explicit environment sentinel to prevent accidental account access.
3516    #[test]
3517    #[ignore = "requires explicit live managed-account discovery authorization"]
3518    fn live_discovery_conforms_for_reviewed_cli_without_inference() {
3519        let executable = reviewed_live_executable("STEADYMUX_CODEX_APP_SERVER_LIVE_DISCOVERY");
3520
3521        let config = OwnedAppServerConfig::codex(
3522            executable,
3523            Duration::from_secs(10),
3524            Duration::from_secs(2),
3525        )
3526        .expect("reviewed owned-child configuration");
3527        assert_eq!(
3528            config.compatible_cli_version(),
3529            CURRENT_STANDALONE_CLI_VERSION
3530        );
3531        let mut client = CodexAppServerClient::spawn(&config).expect("spawn reviewed app-server");
3532        client.initialize().expect("initialize reviewed app-server");
3533        let discovery = client.discover().expect("read-only live discovery");
3534        assert!(discovery.account.plan_type.is_some());
3535        for expected in ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] {
3536            assert!(
3537                discovery
3538                    .model_pages
3539                    .iter()
3540                    .flat_map(|page| &page.models)
3541                    .any(|model| model.id == expected),
3542                "reviewed model catalog entry is missing"
3543            );
3544        }
3545        client.shutdown().expect("reap reviewed app-server");
3546    }
3547
3548    /// Bounded one-turn conformance for a separately authorized managed
3549    /// subscription proof. The model and effort are exact operator inputs; the
3550    /// prompt is fixed and carries no user content. The runtime itself fixes
3551    /// `approvalPolicy=never`, `sandbox=read-only`, and `ephemeral=true`.
3552    #[test]
3553    #[ignore = "requires explicit live managed-account turn authorization"]
3554    fn live_single_turn_conforms_for_exact_model_and_effort() {
3555        let executable = reviewed_live_executable("STEADYMUX_CODEX_APP_SERVER_LIVE_TURN");
3556        assert_eq!(
3557            std::env::var("STEADYMUX_CODEX_APP_SERVER_LIVE_TURN_AUTHORIZED").as_deref(),
3558            Ok("1"),
3559            "acknowledge the single live subscription turn explicitly"
3560        );
3561        let model = std::env::var("STEADYMUX_CODEX_LIVE_MODEL")
3562            .expect("select one exact discovered model ID");
3563        let effort = match std::env::var("STEADYMUX_CODEX_LIVE_EFFORT").as_deref() {
3564            Ok("minimal") => ReasoningEffort::Minimal,
3565            Ok("low") => ReasoningEffort::Low,
3566            Ok("medium") => ReasoningEffort::Medium,
3567            Ok("high") => ReasoningEffort::High,
3568            Ok("xhigh") => ReasoningEffort::Xhigh,
3569            Ok("max") => ReasoningEffort::Max,
3570            Ok("ultra") => ReasoningEffort::Ultra,
3571            _ => panic!("select one exact supported reasoning effort"),
3572        };
3573        let config = OwnedAppServerConfig::codex(
3574            executable,
3575            Duration::from_secs(120),
3576            Duration::from_secs(2),
3577        )
3578        .expect("reviewed owned-child configuration");
3579        let mut client = CodexAppServerClient::spawn(&config).expect("spawn reviewed app-server");
3580        let run = (|| -> Result<TurnRunReceipt, AppServerProcessError> {
3581            client.initialize()?;
3582            client.discover()?;
3583            let turn = client.start_turn(
3584                "steadymux-live-conformance",
3585                "Return the single token STEADYMUX_CANARY.",
3586                &model,
3587                effort,
3588            )?;
3589            client.wait_for_terminal(&turn)
3590        })();
3591        let shutdown = client.shutdown();
3592
3593        let receipt = run.expect("one bounded live turn must reach a terminal result");
3594        let shutdown = shutdown.expect("owned live app-server must be reaped");
3595        assert!(
3596            shutdown.success,
3597            "owned live app-server shutdown must succeed"
3598        );
3599        assert_eq!(receipt.completion.status, TerminalStatus::Completed);
3600        assert_eq!(receipt.completion.selected_model, model);
3601        assert_eq!(receipt.completion.selected_effort, effort);
3602        assert!(
3603            receipt.completion.output.as_deref().is_some_and(
3604                |output| !output.trim().is_empty() && output.len() <= DEFAULT_MAX_LINE_BYTES
3605            ),
3606            "completed live turn must return nonempty bounded output"
3607        );
3608    }
3609
3610    #[test]
3611    fn bounded_reader_rejects_oversized_and_unterminated_frames() {
3612        let mut oversized = BufReader::new(std::io::Cursor::new(vec![b'x'; 17]));
3613        assert!(matches!(
3614            read_bounded_line(&mut oversized, 16),
3615            Err(LineReadError::TooLarge)
3616        ));
3617        let mut unterminated = BufReader::new(std::io::Cursor::new(b"{}".to_vec()));
3618        assert!(matches!(
3619            read_bounded_line(&mut unterminated, 16),
3620            Err(LineReadError::Io)
3621        ));
3622    }
3623
3624    #[test]
3625    #[cfg(unix)]
3626    fn command_arguments_cannot_carry_token_material() {
3627        let shell = std::path::Path::new("/bin/sh")
3628            .canonicalize()
3629            .expect("canonical shell");
3630        let error = OwnedAppServerConfig::new(
3631            shell,
3632            vec![OsString::from("Bearer secret-canary")],
3633            Duration::from_secs(1),
3634            Duration::from_secs(1),
3635            DEFAULT_MAX_LINE_BYTES,
3636            None,
3637        )
3638        .expect_err("token-shaped arguments are rejected");
3639        assert_eq!(error, AppServerProcessError::InvalidConfiguration);
3640        assert!(!format!("{error:?} {error}").contains("secret-canary"));
3641    }
3642
3643    #[test]
3644    fn child_environment_is_explicitly_allowlisted_without_raw_tokens() {
3645        let mut command = Command::new(std::env::current_exe().expect("current test executable"));
3646        command.env("OPENAI_API_KEY", "secret-canary");
3647        configure_managed_child_env(&mut command, None);
3648        let configured_names = command
3649            .get_envs()
3650            .filter_map(|(name, value)| value.map(|_| name.to_string_lossy().into_owned()))
3651            .collect::<Vec<_>>();
3652        assert!(configured_names.iter().all(|name| {
3653            MANAGED_CHILD_ENV_ALLOWLIST
3654                .iter()
3655                .any(|allowed| name == allowed)
3656        }));
3657        assert!(!configured_names.iter().any(|name| name == "OPENAI_API_KEY"));
3658        assert!(!format!("{command:?}").contains("secret-canary"));
3659    }
3660
3661    #[test]
3662    fn os_argument_validation_is_bounded() {
3663        assert!(!valid_arg(OsStr::new("")));
3664        assert!(valid_arg(OsStr::new("app-server")));
3665    }
3666}