diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index e0242af21da8..19885ae97504 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -2134,6 +2134,7 @@ dependencies = [
"codex-utils-cli",
"codex-utils-json-to-toml",
"codex-utils-path-uri",
+ "codex-utils-process",
"codex-utils-pty",
"codex-web-search-extension",
"codex-websocket-auth",
@@ -2550,6 +2551,7 @@ dependencies = [
"codex-utils-cli",
"codex-utils-path",
"codex-utils-path-uri",
+ "codex-utils-process",
"codex-utils-pty",
"codex-utils-redacted-string",
"codex-websocket-auth",
@@ -2965,6 +2967,7 @@ dependencies = [
"codex-utils-path",
"codex-utils-path-uri",
"codex-utils-plugins",
+ "codex-utils-process",
"codex-utils-pty",
"codex-utils-redacted-string",
"codex-utils-stream-parser",
@@ -3084,6 +3087,7 @@ dependencies = [
"codex-utils-path",
"codex-utils-path-uri",
"codex-utils-plugins",
+ "codex-utils-process",
"dirs",
"flate2",
"futures",
@@ -3201,6 +3205,7 @@ dependencies = [
"codex-utils-cargo-bin",
"codex-utils-home-dir",
"codex-utils-path-uri",
+ "codex-utils-process",
"codex-utils-pty",
"codex-utils-redacted-string",
"codex-utils-rustls-provider",
@@ -3459,6 +3464,7 @@ dependencies = [
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-path-uri",
+ "codex-utils-process",
"codex-utils-pty",
"futures",
"gix",
@@ -3634,6 +3640,7 @@ dependencies = [
"codex-utils-absolute-path",
"codex-utils-output-truncation",
"codex-utils-path-uri",
+ "codex-utils-process",
"codex-utils-pty",
"futures",
"libc",
@@ -3794,6 +3801,7 @@ dependencies = [
"codex-protocol",
"codex-secrets",
"codex-terminal-detection",
+ "codex-utils-process",
"codex-utils-template",
"codex-workload-identity",
"core_test_support",
@@ -4005,6 +4013,7 @@ dependencies = [
"codex-protocol",
"codex-response-debug-context",
"codex-secrets",
+ "codex-utils-process",
"codex-utils-redacted-string",
"http 1.4.0",
"pretty_assertions",
@@ -4409,6 +4418,7 @@ dependencies = [
"codex-state",
"codex-utils-absolute-path",
"codex-utils-path",
+ "codex-utils-process",
"pretty_assertions",
"regex",
"serde",
@@ -5064,6 +5074,14 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "codex-utils-process"
+version = "0.0.0"
+dependencies = [
+ "pretty_assertions",
+ "tokio",
+]
+
[[package]]
name = "codex-utils-pty"
version = "0.0.0"
@@ -5254,6 +5272,7 @@ dependencies = [
"codex-otel",
"codex-protocol",
"codex-utils-absolute-path",
+ "codex-utils-process",
"codex-utils-pty",
"codex-utils-string",
"dirs-next",
diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml
index adceeb5ed902..a8246dbcb1e8 100644
--- a/codex-rs/Cargo.toml
+++ b/codex-rs/Cargo.toml
@@ -125,6 +125,7 @@ members = [
"utils/image",
"utils/json-to-toml",
"utils/home-dir",
+ "utils/process",
"utils/pty",
"utils/readiness",
"utils/redacted-string",
@@ -300,6 +301,7 @@ codex-utils-output-truncation = { path = "utils/output-truncation" }
codex-utils-path = { path = "utils/path-utils" }
codex-utils-path-uri = { path = "utils/path-uri" }
codex-utils-plugins = { path = "utils/plugins" }
+codex-utils-process = { path = "utils/process" }
codex-utils-pty = { path = "utils/pty" }
codex-utils-redacted-string = { path = "utils/redacted-string" }
codex-utils-rustls-provider = { path = "utils/rustls-provider" }
diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml
index ad0a5fbab362..8834c54b7ec5 100644
--- a/codex-rs/app-server/Cargo.toml
+++ b/codex-rs/app-server/Cargo.toml
@@ -25,6 +25,7 @@ doctest = false
workspace = true
[dependencies]
+codex-utils-process = { workspace = true }
anyhow = { workspace = true }
base64 = { workspace = true }
axum = { workspace = true, default-features = false, features = [
diff --git a/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs b/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs
index 905f721f1d45..b01e490b8388 100644
--- a/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs
+++ b/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs
@@ -106,7 +106,7 @@ pub(crate) async fn doctor_feedback_report(
}
fn doctor_command(executable: &Path, cwd: &Path, codex_home: &Path) -> Command {
- let mut command = Command::new(executable);
+ let mut command = Command::from(codex_utils_process::background_command(executable));
command
.arg("--cd")
.arg(cwd)
diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs
index 5d26c68e78ec..d21bebfc375c 100644
--- a/codex-rs/app-server/tests/suite/v2/model_list.rs
+++ b/codex-rs/app-server/tests/suite/v2/model_list.rs
@@ -131,7 +131,15 @@ requires_openai_auth = true
})
.await?;
let enabled = user_enablement.unwrap_or(app_enablement) && catalog_opt_in;
- let expected = if enabled { &remote } else { &bundled };
+ let expected: &[ModelPreset] = if enabled {
+ &remote
+ } else if catalog_opt_in {
+ // The rollout gate suppresses discovery, but an explicit catalog still
+ // prevents bundled models from being advertised for this provider.
+ &[]
+ } else {
+ &bundled
+ };
assert_eq!(
response,
ModelListResponse {
diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml
index 4fed7668d3a4..399230d3f4d1 100644
--- a/codex-rs/cli/Cargo.toml
+++ b/codex-rs/cli/Cargo.toml
@@ -23,6 +23,7 @@ doctest = false
workspace = true
[dependencies]
+codex-utils-process = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true, features = ["derive", "env"] }
diff --git a/codex-rs/cli/src/doctor/desktop/windows_security.rs b/codex-rs/cli/src/doctor/desktop/windows_security.rs
index 94e95edca8a1..d54f1b593668 100644
--- a/codex-rs/cli/src/doctor/desktop/windows_security.rs
+++ b/codex-rs/cli/src/doctor/desktop/windows_security.rs
@@ -72,7 +72,7 @@ async fn query_channel(wevtutil: &Path, channel: Channel) -> Option>()
.join(" or ");
- let mut child = Command::new(wevtutil)
+ let mut child = Command::from(codex_utils_process::background_command(wevtutil))
.args(["qe", channel.1])
.arg(format!(
"/q:*[System[({events}) and TimeCreated[timediff(@SystemTime) <= 604800000]]]"
diff --git a/codex-rs/cli/src/doctor/security.rs b/codex-rs/cli/src/doctor/security.rs
index 0413f9c0a253..64141dd976be 100644
--- a/codex-rs/cli/src/doctor/security.rs
+++ b/codex-rs/cli/src/doctor/security.rs
@@ -236,7 +236,7 @@ async fn endpoint_products() -> EndpointInspection {
#[cfg(any(target_os = "macos", target_os = "windows"))]
async fn product_command(program: impl AsRef, args: &[&str]) -> Option
---
- suffix
- suffix
+ suffix
+PlainText
diff --git a/codex-rs/tui/src/unarchive_prompt_tests.rs b/codex-rs/tui/src/unarchive_prompt_tests.rs
index 5c6df51e9490..4a7a066fe67a 100644
--- a/codex-rs/tui/src/unarchive_prompt_tests.rs
+++ b/codex-rs/tui/src/unarchive_prompt_tests.rs
@@ -32,6 +32,7 @@ fn render(screen: &UnarchivePrompt) -> String {
fn resume_prompt_snapshot() {
let screen = prompt(SessionStartAction::Resume(
ResumeModelSettings::RestoreFromThread,
+ crate::resume_permissions::ResumePermissions::default(),
));
insta::assert_snapshot!(render(&screen), @"
This conversation is archived
diff --git a/codex-rs/tui/tests/suite/directory_trust.rs b/codex-rs/tui/tests/suite/directory_trust.rs
index b9efce788eb6..f44a19a9ee56 100644
--- a/codex-rs/tui/tests/suite/directory_trust.rs
+++ b/codex-rs/tui/tests/suite/directory_trust.rs
@@ -4,6 +4,7 @@ use super::focus_palette::PtyCodex;
use super::focus_palette::write_test_config;
use anyhow::Result;
use codex_app_server_protocol::JSONRPCMessage;
+use codex_app_server_protocol::RequestId;
use futures::SinkExt;
use futures::StreamExt;
use pretty_assertions::assert_eq;
@@ -19,7 +20,10 @@ use tokio_tungstenite::tungstenite::Message;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn connected_trust_cancellation_and_acceptance_control_task_creation() -> Result<()> {
for trust_level in [None, Some("untrusted")] {
- let repo_root = codex_utils_cargo_bin::repo_root()?;
+ let workspace = tempfile::tempdir()?;
+ let repo_root = workspace.path().canonicalize()?;
+ std::fs::create_dir(repo_root.join(".git"))?;
+ std::fs::write(repo_root.join(".git/HEAD"), "ref: refs/heads/main\n")?;
let codex_home = tempfile::tempdir_in("/tmp")?;
// The server's trust decision must win over the client's trusted-folder setting.
write_test_config(codex_home.path(), &repo_root)?;
@@ -80,11 +84,7 @@ async fn connected_trust_cancellation_and_acceptance_control_task_creation() ->
json!({"account": {"type": "apiKey"}, "requiresOpenaiAuth": false})
}
"config/read" => {
- if request
- .params
- .as_ref()
- .is_some_and(|params| params["includeLayers"] == true)
- {
+ if matches!(&request.id, RequestId::String(id) if id.starts_with("tui-project-trust-read-")) {
trust_reads.fetch_add(1, Ordering::SeqCst);
}
json!({"config": {"model": "gpt-5.6-terra", "projects": {
diff --git a/codex-rs/tui/tests/suite/mod.rs b/codex-rs/tui/tests/suite/mod.rs
index e827d70b614d..5d88a0be9b93 100644
--- a/codex-rs/tui/tests/suite/mod.rs
+++ b/codex-rs/tui/tests/suite/mod.rs
@@ -6,6 +6,8 @@ mod directory_trust;
#[cfg(unix)]
mod focus_palette;
#[cfg(unix)]
+mod provider_defaults;
+#[cfg(unix)]
mod reconnect;
mod resize_reflow;
#[cfg(unix)]
diff --git a/codex-rs/tui/tests/suite/provider_defaults.rs b/codex-rs/tui/tests/suite/provider_defaults.rs
new file mode 100644
index 000000000000..3ae9c8532ee7
--- /dev/null
+++ b/codex-rs/tui/tests/suite/provider_defaults.rs
@@ -0,0 +1,111 @@
+//! Resume and fork picker and --last provider defaults through the real CLI and app server.
+
+use super::focus_palette::PtyCodex;
+use super::focus_palette::write_test_config;
+use anyhow::Result;
+use anyhow::ensure;
+use app_test_support::create_fake_rollout;
+use std::process::Stdio;
+use std::time::Duration;
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn history_lookup_uses_server_provider_with_local_and_embedded_servers() -> Result<()> {
+ let codex = codex_utils_cargo_bin::cargo_bin("codex")?;
+ let cwd = codex_utils_cargo_bin::repo_root()?;
+ for action in ["resume", "fork"] {
+ for (embedded, last, explicit) in [
+ (false, false, false),
+ (true, false, false),
+ (false, true, false),
+ (true, true, false),
+ (false, true, true),
+ (false, false, true),
+ ] {
+ let home = tempfile::tempdir_in("/tmp")?;
+ write_test_config(home.path(), &cwd)?;
+ let config_path = home.path().join("config.toml");
+ let config = std::fs::read_to_string(&config_path)?;
+ std::fs::write(
+ config_path,
+ format!(
+ "{config}\n[model_providers.server-provider]\nname = 'Server provider'\nbase_url = 'http://127.0.0.1:9/v1'\nwire_api = 'responses'\nrequires_openai_auth = false\n[projects.'/']\ntrust_level = 'trusted'\n"
+ ),
+ )?;
+ for (hour, provider, preview) in [
+ ("10", "openai", "Client provider history"),
+ ("11", "server-provider", "Server provider history"),
+ ] {
+ create_fake_rollout(
+ home.path(),
+ &format!("2025-01-02T{hour}-00-00"),
+ &format!("2025-01-02T{hour}:00:00Z"),
+ preview,
+ Some(provider),
+ /*git_info*/ None,
+ )?;
+ }
+ let mut daemon = tokio::process::Command::new(&codex)
+ .args([
+ "app-server",
+ "--listen",
+ "unix://",
+ "-c",
+ "model_provider=\"server-provider\"",
+ ])
+ .env("CODEX_HOME", home.path())
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::inherit())
+ .kill_on_drop(true)
+ .spawn()?;
+ let socket = codex_app_server_client::app_server_control_socket_path(home.path())?;
+ tokio::time::timeout(Duration::from_secs(/*secs*/ 30), async {
+ loop {
+ if tokio::net::UnixStream::connect(socket.as_path())
+ .await
+ .is_ok()
+ {
+ return Ok::<(), anyhow::Error>(());
+ }
+ ensure!(
+ daemon.try_wait()?.is_none(),
+ "app server exited before opening its socket"
+ );
+ tokio::time::sleep(Duration::from_millis(/*millis*/ 20)).await;
+ }
+ })
+ .await??;
+ let remote_endpoint = format!("unix://{}", socket.as_path().display());
+ let mut args = vec![action, "--all"];
+ if embedded {
+ args.push("--no-daemon");
+ }
+ if last {
+ args.push("--last");
+ }
+ if explicit {
+ args.extend([
+ "--remote",
+ remote_endpoint.as_str(),
+ "-c",
+ "model_provider=\"openai\"",
+ ]);
+ }
+ let mut terminal = PtyCodex::start_cli(&cwd, home, &args)?;
+ let (expected, excluded) = if embedded || explicit {
+ ("Client provider history", "Server provider history")
+ } else {
+ ("Server provider history", "Client provider history")
+ };
+ terminal.wait_for_screen(expected)?;
+ ensure!(
+ !terminal.screen_contains(excluded),
+ "{action}: unexpected provider history"
+ );
+ drop(terminal);
+ daemon.kill().await?;
+ daemon.wait().await?;
+ }
+ }
+ Ok(())
+}
diff --git a/codex-rs/utils/process/BUILD.bazel b/codex-rs/utils/process/BUILD.bazel
new file mode 100644
index 000000000000..c089719d4e25
--- /dev/null
+++ b/codex-rs/utils/process/BUILD.bazel
@@ -0,0 +1,6 @@
+load("//:defs.bzl", "codex_rust_crate")
+
+codex_rust_crate(
+ name = "process",
+ crate_name = "codex_utils_process",
+)
diff --git a/codex-rs/utils/process/Cargo.toml b/codex-rs/utils/process/Cargo.toml
new file mode 100644
index 000000000000..3d5bbd66ad92
--- /dev/null
+++ b/codex-rs/utils/process/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "codex-utils-process"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[lints]
+workspace = true
+
+[dev-dependencies]
+pretty_assertions = { workspace = true }
+tokio = { workspace = true, features = ["macros", "process", "rt-multi-thread"] }
+
+[lib]
+doctest = false
diff --git a/codex-rs/utils/process/src/background_command_tests.rs b/codex-rs/utils/process/src/background_command_tests.rs
new file mode 100644
index 000000000000..216b3279c2fb
--- /dev/null
+++ b/codex-rs/utils/process/src/background_command_tests.rs
@@ -0,0 +1,54 @@
+//! Verifies detached background children stay console-free through std and Tokio launches.
+
+use super::background_command;
+use pretty_assertions::assert_eq;
+use std::os::windows::process::CommandExt;
+use std::process::Command;
+
+#[tokio::test]
+async fn background_commands_have_no_console_under_detached_parent() {
+ const TEST_NAME: &str = "tests::background_commands_have_no_console_under_detached_parent";
+ const PHASE: &str = "CODEX_TEST_BACKGROUND_COMMAND_PHASE";
+ let executable = std::env::current_exe().expect("test executable");
+ match std::env::var(PHASE).as_deref() {
+ Ok("probe") => {
+ #[link(name = "kernel32")]
+ unsafe extern "system" {
+ #[link_name = "GetConsoleWindow"]
+ fn get_console_window() -> isize;
+ }
+ assert_eq!(unsafe { get_console_window() }, 0);
+ println!("background stdout");
+ eprintln!("background stderr");
+ }
+ Ok("detached") => {
+ for asynchronous in [false, true] {
+ let mut command = background_command(&executable);
+ command
+ .args(["--exact", TEST_NAME, "--nocapture"])
+ .env(PHASE, "probe");
+ let output = if asynchronous {
+ tokio::process::Command::from(command).output().await
+ } else {
+ command.output()
+ }
+ .expect("run background command");
+ assert!(output.status.success(), "probe failed: {output:?}");
+ assert!(String::from_utf8_lossy(&output.stdout).contains("background stdout"));
+ assert!(String::from_utf8_lossy(&output.stderr).contains("background stderr"));
+ }
+ }
+ _ => {
+ let output = Command::new(executable)
+ .args(["--exact", TEST_NAME, "--nocapture"])
+ .env(PHASE, "detached")
+ .creation_flags(/*flags*/ 0x0000_0008) // DETACHED_PROCESS
+ .output()
+ .expect("spawn detached parent");
+ assert!(
+ output.status.success(),
+ "detached parent failed: {output:?}"
+ );
+ }
+ }
+}
diff --git a/codex-rs/utils/process/src/lib.rs b/codex-rs/utils/process/src/lib.rs
new file mode 100644
index 000000000000..b3c449182195
--- /dev/null
+++ b/codex-rs/utils/process/src/lib.rs
@@ -0,0 +1,26 @@
+//! Command construction for background processes that must not allocate a Windows console.
+//! Callers retain control of environment, stdio, and process lifetime. Interactive,
+//! detached, and private-desktop launches must select their own console policy.
+
+use std::ffi::OsStr;
+use std::process::Command;
+
+/// Construct a background command, suppressing console allocation on Windows.
+/// Convert with `tokio::process::Command::from` for asynchronous execution.
+/// Subsequent Windows `creation_flags` calls must retain `CREATE_NO_WINDOW`.
+pub fn background_command(program: impl AsRef) -> Command {
+ #[cfg(windows)]
+ {
+ use std::os::windows::process::CommandExt;
+
+ let mut command = Command::new(program);
+ command.creation_flags(/*flags*/ 0x0800_0000); // CREATE_NO_WINDOW
+ command
+ }
+ #[cfg(not(windows))]
+ Command::new(program)
+}
+
+#[cfg(all(test, windows))]
+#[path = "background_command_tests.rs"]
+mod tests;
diff --git a/codex-rs/utils/pty/src/child_command.rs b/codex-rs/utils/pty/src/child_command.rs
index 26d053acca27..452e3ecddec6 100644
--- a/codex-rs/utils/pty/src/child_command.rs
+++ b/codex-rs/utils/pty/src/child_command.rs
@@ -15,8 +15,6 @@ use std::path::Path;
use std::process::Stdio as TokioStdio;
#[cfg(windows)]
use winapi::um::winbase::CREATE_NO_WINDOW;
-#[cfg(windows)]
-use winapi::um::winbase::CREATE_SUSPENDED;
use crate::child::Child;
use crate::child::ChildKind;
@@ -240,19 +238,10 @@ impl Command {
self
}
- /// Set the Windows process creation flags, replacing any previously selected flags.
- #[cfg(windows)]
- pub fn creation_flags(&mut self, flags: u32) -> &mut Self {
- self.inner.creation_flags(flags);
- self
- }
/// Preserve Job Object assignment before the child begins executing on Windows.
#[cfg(windows)]
pub fn prepare_suspended_spawn(&mut self, job: &crate::JobObject) {
job.prepare_suspended_spawn(&mut self.inner);
- // Tokio's creation_flags replaces, rather than adds to, the flags.
- self.inner
- .creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED);
}
/// Reject original inputs that std replaced with a NUL-free placeholder.
diff --git a/codex-rs/utils/pty/src/win/job.rs b/codex-rs/utils/pty/src/win/job.rs
index b06035f72d22..f963c6f55fd5 100644
--- a/codex-rs/utils/pty/src/win/job.rs
+++ b/codex-rs/utils/pty/src/win/job.rs
@@ -14,6 +14,7 @@ use winapi::um::jobapi2::SetInformationJobObject;
use winapi::um::jobapi2::TerminateJobObject;
use winapi::um::processthreadsapi::OpenProcess;
use winapi::um::processthreadsapi::TerminateProcess;
+use winapi::um::winbase::CREATE_NO_WINDOW;
use winapi::um::winbase::CREATE_SUSPENDED;
use winapi::um::winnt::HANDLE;
use winapi::um::winnt::JOB_OBJECT_LIMIT_BREAKAWAY_OK;
@@ -121,9 +122,14 @@ impl JobObject {
}
}
- /// Prevents a child from running before it can be assigned to this job.
+ /// Prepares a background child to be assigned to this job before it runs.
+ ///
+ /// Replaces all creation flags with `CREATE_SUSPENDED | CREATE_NO_WINDOW`.
+ /// Interactive children that need to inherit a console require an explicit launch.
pub fn prepare_suspended_spawn(&self, command: &mut Command) {
- command.creation_flags(CREATE_SUSPENDED).kill_on_drop(true);
+ command
+ .creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED)
+ .kill_on_drop(true);
}
/// Assigns and resumes a suspended child, returning whether assignment succeeded.
@@ -165,7 +171,29 @@ impl JobObject {
}
}
- /// Starts a process only after assigning it to this Job Object.
+ /// Starts a console-free background process, falling back when containment is unavailable.
+ /// The returned job owns the contained process tree when assignment succeeds.
+ pub fn spawn_background(command: &mut Command) -> io::Result<(Child, Option)> {
+ Self::spawn_background_with_job(command, Self::create())
+ }
+
+ fn spawn_background_with_job(
+ command: &mut Command,
+ job: io::Result,
+ ) -> io::Result<(Child, Option)> {
+ match job.and_then(|job| job.spawn_contained(command).map(|child| (child, job))) {
+ Ok((child, job)) => Ok((child, Some(job))),
+ Err(_) => {
+ // Remove suspension before retrying, including after failed assignment.
+ command.creation_flags(CREATE_NO_WINDOW).kill_on_drop(true);
+ command.spawn().map(|child| (child, None))
+ }
+ }
+ }
+
+ /// Starts a background child without a console, assigning it to this job before it runs.
+ ///
+ /// Replaces existing creation flags as in [`Self::prepare_suspended_spawn`].
pub fn spawn_contained(&self, command: &mut Command) -> io::Result {
self.prepare_suspended_spawn(command);
let child = command.spawn()?;
@@ -230,3 +258,7 @@ impl AsRawHandle for JobObject {
self.handle.as_raw_handle()
}
}
+
+#[cfg(test)]
+#[path = "job_tests.rs"]
+mod tests;
diff --git a/codex-rs/utils/pty/src/win/job_tests.rs b/codex-rs/utils/pty/src/win/job_tests.rs
new file mode 100644
index 000000000000..3d430d363c74
--- /dev/null
+++ b/codex-rs/utils/pty/src/win/job_tests.rs
@@ -0,0 +1,87 @@
+//! Verifies console suppression through contained launches and both containment fallbacks.
+
+use super::*;
+use pretty_assertions::assert_eq;
+use std::os::windows::process::CommandExt;
+use std::process::Stdio;
+use std::time::Duration;
+use winapi::um::winnt::JOB_OBJECT_LIMIT_ACTIVE_PROCESS;
+
+#[tokio::test]
+async fn background_launches_keep_consoles_hidden_when_containment_fails() -> anyhow::Result<()> {
+ const TEST_NAME: &str =
+ "win::job::tests::background_launches_keep_consoles_hidden_when_containment_fails";
+ const PHASE: &str = "CODEX_TEST_BACKGROUND_JOB_PHASE";
+ let executable = std::env::current_exe()?;
+ match std::env::var(PHASE).as_deref() {
+ Ok("probe") => {
+ assert!(unsafe { winapi::um::wincon::GetConsoleWindow() }.is_null());
+ println!("background probe completed");
+ return Ok(());
+ }
+ Ok("hold") => {
+ std::thread::sleep(Duration::from_secs(/*secs*/ 30));
+ return Ok(());
+ }
+ Ok("parent") => {}
+ _ => {
+ let output = std::process::Command::new(executable)
+ .args(["--exact", TEST_NAME, "--nocapture"])
+ .env(PHASE, "parent")
+ .creation_flags(winapi::um::winbase::DETACHED_PROCESS)
+ .output()?;
+ assert!(output.status.success(), "detached probe failed: {output:?}");
+ return Ok(());
+ }
+ }
+
+ // Fill a one-process job so assigning the next suspended child must fail.
+ // The parent remains outside this job, allowing the uncontained retry to run.
+ let full_job = JobObject::create()?;
+ let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
+ limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_ACTIVE_PROCESS
+ | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
+ | JOB_OBJECT_LIMIT_BREAKAWAY_OK;
+ limits.BasicLimitInformation.ActiveProcessLimit = 1;
+ let configured = unsafe {
+ SetInformationJobObject(
+ full_job.handle.as_raw_handle().cast(),
+ JobObjectExtendedLimitInformation,
+ std::ptr::addr_of_mut!(limits).cast(),
+ std::mem::size_of::() as u32,
+ )
+ };
+ anyhow::ensure!(configured != 0, "{}", io::Error::last_os_error());
+ let mut holder_command = Command::new(&executable);
+ holder_command
+ .args(["--exact", TEST_NAME])
+ .env(PHASE, "hold");
+ let mut holder = full_job.spawn_contained(&mut holder_command)?;
+
+ for (case, job, expected_containment) in [
+ (
+ "creation failure",
+ Err(io::Error::other("forced creation failure")),
+ false,
+ ),
+ ("assignment failure", Ok(full_job), false),
+ ("contained", JobObject::create(), true),
+ ] {
+ let mut command = Command::new(&executable);
+ command
+ .args(["--exact", TEST_NAME, "--nocapture"])
+ .env(PHASE, "probe")
+ .stdin(Stdio::null())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped());
+ let (child, job) = JobObject::spawn_background_with_job(&mut command, job)?;
+ assert_eq!(job.is_some(), expected_containment, "{case}");
+ let output =
+ tokio::time::timeout(Duration::from_secs(/*secs*/ 10), child.wait_with_output())
+ .await??;
+ assert!(output.status.success(), "{case}: {output:?}");
+ assert!(String::from_utf8_lossy(&output.stdout).contains("background probe completed"));
+ }
+ tokio::time::timeout(Duration::from_secs(/*secs*/ 10), holder.wait()).await??;
+ Ok(())
+}
diff --git a/codex-rs/windows-sandbox-rs/Cargo.toml b/codex-rs/windows-sandbox-rs/Cargo.toml
index b182ed8c9ecd..65332f3506bf 100644
--- a/codex-rs/windows-sandbox-rs/Cargo.toml
+++ b/codex-rs/windows-sandbox-rs/Cargo.toml
@@ -26,6 +26,7 @@ path = "src/bin/managed_deny_probe/main.rs"
workspace = true
[dependencies]
+codex-utils-process = { workspace = true }
codex-http-client = { workspace = true }
anyhow = "1.0"
base64 = { workspace = true }
diff --git a/codex-rs/windows-sandbox-rs/src/bin/command_runner/win/cwd_junction.rs b/codex-rs/windows-sandbox-rs/src/bin/command_runner/win/cwd_junction.rs
index 7ddd391c3498..9243cc67aa76 100644
--- a/codex-rs/windows-sandbox-rs/src/bin/command_runner/win/cwd_junction.rs
+++ b/codex-rs/windows-sandbox-rs/src/bin/command_runner/win/cwd_junction.rs
@@ -100,7 +100,7 @@ pub fn create_cwd_junction(requested_cwd: &Path, log_dir: Option<&Path>) -> Opti
&format!("junction: creating via cmd /c mklink /J {link_quoted} {target_quoted}"),
log_dir,
);
- let output = match std::process::Command::new("cmd")
+ let output = match codex_utils_process::background_command("cmd")
.raw_arg("/c")
.raw_arg("mklink")
.raw_arg("/J")
diff --git a/codex-rs/windows-sandbox-rs/src/deny_read_resolver.rs b/codex-rs/windows-sandbox-rs/src/deny_read_resolver.rs
index f7b39839f919..ed11fb14a36e 100644
--- a/codex-rs/windows-sandbox-rs/src/deny_read_resolver.rs
+++ b/codex-rs/windows-sandbox-rs/src/deny_read_resolver.rs
@@ -7,7 +7,6 @@ use codex_protocol::permissions::windows_deny_read_glob_scan;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashSet;
use std::path::PathBuf;
-use std::process::Command;
#[path = "deny_read_walker.rs"]
mod walker;
@@ -100,7 +99,7 @@ pub fn resolve_windows_deny_read_paths(
}
fn ripgrep_files(scan_plan: &GlobScanPlan) -> Result>, String> {
- let mut command = Command::new("rg");
+ let mut command = codex_utils_process::background_command("rg");
command
.arg("--files")
.arg("--hidden")
diff --git a/codex-rs/windows-sandbox-rs/src/setup.rs b/codex-rs/windows-sandbox-rs/src/setup.rs
index c40a56c996a2..484f28671324 100644
--- a/codex-rs/windows-sandbox-rs/src/setup.rs
+++ b/codex-rs/windows-sandbox-rs/src/setup.rs
@@ -386,7 +386,7 @@ fn run_setup_refresh_payload(b64: &str, codex_home: &Path) -> Result<()> {
}
};
// Refresh should never request elevation; ensure verb isn't set and we don't trigger UAC.
- let mut cmd = Command::new(&exe);
+ let mut cmd = codex_utils_process::background_command(&exe);
crate::launch_environment::configure_command(&mut cmd, b64)?;
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
@@ -926,6 +926,10 @@ fn verify_setup_completed(codex_home: &Path) -> Result<()> {
}
}
+#[cfg(test)]
+#[path = "setup_refresh_tests.rs"]
+mod refresh_tests;
+
fn run_setup_exe(
payload: &ElevationPayload,
needs_elevation: bool,
diff --git a/codex-rs/windows-sandbox-rs/src/setup_refresh_tests.rs b/codex-rs/windows-sandbox-rs/src/setup_refresh_tests.rs
new file mode 100644
index 000000000000..0a9f5d5315e8
--- /dev/null
+++ b/codex-rs/windows-sandbox-rs/src/setup_refresh_tests.rs
@@ -0,0 +1,64 @@
+//! Verifies refresh helpers stay console-free when launched by a detached daemon.
+
+use std::fs;
+use std::os::windows::process::CommandExt;
+use std::path::PathBuf;
+use std::process::Command;
+
+use anyhow::Result;
+use pretty_assertions::assert_eq;
+use windows_sys::Win32::System::Console::GetConsoleWindow;
+use windows_sys::Win32::System::Threading::DETACHED_PROCESS;
+
+use super::run_setup_refresh_payload;
+
+const DETACHED_TEST: &str = "setup::refresh_tests::refresh_helper_has_no_console";
+const HELPER_TEST: &str = "setup::refresh_tests::refresh_console_probe";
+const HOME_ENV: &str = "CODEX_TEST_REFRESH_CONSOLE_HOME";
+
+#[test]
+fn refresh_helper_has_no_console() -> Result<()> {
+ if let Some(home) = std::env::var_os(HOME_ENV).map(PathBuf::from) {
+ assert_eq!(unsafe { GetConsoleWindow() }, 0);
+ run_setup_refresh_payload(HELPER_TEST, &home)?;
+ assert_eq!(fs::read(home.join("probe"))?, b"no console");
+ return Ok(());
+ }
+
+ // Re-execute detached with a disposable fake setup helper beside it. This
+ // exercises the production refresh launch without provisioning sandbox users.
+ let temporary = tempfile::tempdir()?;
+ let executable = std::env::current_exe()?;
+ let detached = temporary.path().join("detached.exe");
+ fs::copy(&executable, &detached)?;
+ fs::copy(
+ &executable,
+ temporary.path().join("codex-windows-sandbox-setup.exe"),
+ )?;
+ let output = Command::new(detached)
+ .args(["--exact", DETACHED_TEST, "--nocapture"])
+ .env(HOME_ENV, temporary.path())
+ .creation_flags(DETACHED_PROCESS)
+ .output()?;
+ assert!(
+ output.status.success(),
+ "detached refresh probe failed:\n{}\n{}",
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr),
+ );
+ assert_eq!(fs::read(temporary.path().join("probe"))?, b"no console");
+ Ok(())
+}
+
+#[test]
+fn refresh_console_probe() -> Result<()> {
+ // The setup launcher supplies this test name as its single payload argument.
+ // Ordinary test runs must not require their own parent to be console-free.
+ if std::env::args().nth(1).as_deref() != Some(HELPER_TEST) {
+ return Ok(());
+ }
+ let home = PathBuf::from(std::env::var_os(HOME_ENV).expect("detached probe home"));
+ assert_eq!(unsafe { GetConsoleWindow() }, 0);
+ fs::write(home.join("probe"), b"no console")?;
+ Ok(())
+}