1use 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#[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#[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
98pub 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
105pub 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_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#[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 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 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 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 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 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 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 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 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 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 ¤t,
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(¤t).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#[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 #[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 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 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 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
1022pub 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 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
1250pub 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 pub fn require_reviewed_cli_version(&mut self) {
1319 self.required_cli_version = Some(CURRENT_STANDALONE_CLI_VERSION.to_owned());
1320 }
1321
1322 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(¬ification) {
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 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 #[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 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 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 #[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 #[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 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 fs::write(
3169 &qualification_state,
3170 b"refreshed content-free qualification state",
3171 )
3172 .expect("write unrelated qualification state");
3173 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 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 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 #[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 #[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}