Skip to content
98 changes: 98 additions & 0 deletions code-rs/core/src/agent_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1895,6 +1895,7 @@ async fn execute_model_with_permissions(
}

cmd.args(final_args.clone());
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
for (k, v) in &env {
Expand Down Expand Up @@ -2829,6 +2830,44 @@ mod tests {
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};

#[cfg(unix)]
static STDIN_LOCK: Mutex<()> = Mutex::new(());

#[cfg(unix)]
struct StdinRedirectGuard {
saved_stdin_fd: i32,
read_fd: i32,
write_fd: i32,
}

#[cfg(unix)]
impl StdinRedirectGuard {
fn install_pipe_as_stdin() -> Self {
let mut fds = [0; 2];
assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0, "pipe");
let saved_stdin_fd = unsafe { libc::dup(libc::STDIN_FILENO) };
assert!(saved_stdin_fd >= 0, "dup stdin");
assert_eq!(unsafe { libc::dup2(fds[0], libc::STDIN_FILENO) }, libc::STDIN_FILENO, "dup2 stdin");
Self {
saved_stdin_fd,
read_fd: fds[0],
write_fd: fds[1],
}
}
}

#[cfg(unix)]
impl Drop for StdinRedirectGuard {
fn drop(&mut self) {
unsafe {
assert_eq!(libc::dup2(self.saved_stdin_fd, libc::STDIN_FILENO), libc::STDIN_FILENO, "restore stdin");
libc::close(self.saved_stdin_fd);
libc::close(self.read_fd);
libc::close(self.write_fd);
}
}
}

#[test]
fn drops_empty_names() {
assert_eq!(normalize_agent_name(None), None);
Expand Down Expand Up @@ -2956,6 +2995,41 @@ mod tests {
assert_eq!(output.trim(), "current");
}

#[cfg(unix)]
#[tokio::test]
async fn read_only_agents_redirect_stdin_away_from_parent_pipe() {
let _env_lock = env_lock().lock().expect("env lock");
let _stdin_lock = STDIN_LOCK.lock().expect("stdin lock");
let _reset_binary = EnvReset::capture("CODE_BINARY_PATH");

let dir = tempdir().expect("tempdir");
let current = script_path(dir.path(), "current");
write_stdin_mode_script(&current);

let _stdin_guard = StdinRedirectGuard::install_pipe_as_stdin();

unsafe {
std::env::set_var("CODE_BINARY_PATH", &current);
}

let output = execute_model_with_permissions(
"agent-test",
"code-gpt-5.3-codex",
"ok",
true,
None,
None,
ReasoningEffort::Low,
None,
None,
None,
)
.await
.expect("execute read-only agent");

assert_eq!(output.trim(), "detached");
}

#[cfg(not(target_os = "windows"))]
#[tokio::test]
async fn claude_agent_uses_local_install_when_not_on_path() {
Expand Down Expand Up @@ -3071,6 +3145,30 @@ mod tests {
std::fs::set_permissions(path, perms).expect("chmod script");
}

#[cfg(unix)]
fn write_stdin_mode_script(path: &Path) {
let script = r#"#!/bin/sh
python3 - <<'PY'
import os
import stat

mode = os.fstat(0).st_mode
if stat.S_ISFIFO(mode):
print("fifo")
else:
print("detached")
PY
exit 0
"#;
std::fs::write(path, script).expect("write stdin mode script");
let mut perms = std::fs::metadata(path)
.expect("script metadata")
.permissions();
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).expect("chmod script");
}

#[test]
fn gemini_config_dir_is_injected_when_missing_api_key() {
let tmp = tempfile::tempdir().expect("tempdir");
Expand Down
2 changes: 1 addition & 1 deletion code-rs/core/src/codex/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2210,7 +2210,7 @@ impl Session {
command.arg(json);

// Fire-and-forget – we do not wait for completion.
if let Err(e) = crate::spawn::spawn_std_command_with_retry(&mut command) {
if let Err(e) = crate::spawn::spawn_background_command_with_retry(&mut command) {
warn!("failed to spawn notifier '{}': {e}", notify_command[0]);
}
}
Expand Down
187 changes: 185 additions & 2 deletions code-rs/core/src/spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,27 @@ pub const CODEX_SANDBOX_ENV_VAR: &str = "CODEX_SANDBOX";

const SPAWN_RETRY_DELAYS_MS: [u64; 3] = [0, 10, 50];

#[cfg(unix)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UnixChildSessionStrategy {
NewSession,
NewProcessGroup,
}

#[cfg(unix)]
fn unix_child_session_strategy(stdio_policy: StdioPolicy) -> UnixChildSessionStrategy {
match stdio_policy {
// Shell tool commands are non-interactive, but they may launch their own
// long-lived descendants (for example via `nohup ... &`). Starting the
// shell tool in a new session ensures those descendants cannot retain
// the TUI's controlling terminal and steal foreground ownership.
StdioPolicy::RedirectForShellTool => UnixChildSessionStrategy::NewSession,
// Interactive children should keep terminal semantics while still being
// isolated in their own process group for targeted signal handling.
StdioPolicy::Inherit => UnixChildSessionStrategy::NewProcessGroup,
}
}

fn is_temporary_resource_error(err: &io::Error) -> bool {
err.kind() == io::ErrorKind::WouldBlock
|| matches!(err.raw_os_error(), Some(35) | Some(libc::ENOMEM))
Expand Down Expand Up @@ -80,6 +101,34 @@ pub fn spawn_std_command_with_retry(
spawn_with_retry_blocking(|| cmd.spawn())
}

/// Spawn a fire-and-forget helper without sharing this process's controlling
/// terminal. This avoids job-control collisions with the TUI when background
/// helpers are launched from interactive sessions.
pub fn spawn_background_command_with_retry(
cmd: &mut std::process::Command,
) -> io::Result<std::process::Child> {
cmd.stdin(Stdio::null());

#[cfg(unix)]
{
use std::os::unix::process::CommandExt;

unsafe {
cmd.pre_exec(|| {
if libc::setsid() == -1 {
let err = io::Error::last_os_error();
if err.raw_os_error() != Some(libc::EPERM) {
return Err(err);
}
}
Ok(())
});
}
}

spawn_std_command_with_retry(cmd)
}

pub async fn spawn_tokio_command_with_retry(cmd: &mut Command) -> io::Result<Child> {
spawn_with_retry_async(|| cmd.spawn()).await
}
Expand Down Expand Up @@ -135,9 +184,24 @@ pub(crate) async fn spawn_child_async(
StdioPolicy::RedirectForShellTool => crate::cgroup::default_exec_memory_max_bytes(),
StdioPolicy::Inherit => None,
};
#[cfg(unix)]
let session_strategy = unix_child_session_strategy(stdio_policy);
cmd.pre_exec(move || {
// Start a new process group
let _ = libc::setpgid(0, 0);
#[cfg(unix)]
match session_strategy {
UnixChildSessionStrategy::NewSession => {
if libc::setsid() == -1 {
let err = std::io::Error::last_os_error();
if err.raw_os_error() != Some(libc::EPERM) {
return Err(err);
}
}
}
UnixChildSessionStrategy::NewProcessGroup => {
// Start a new process group.
let _ = libc::setpgid(0, 0);
}
}
#[cfg(target_os = "linux")]
{
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 {
Expand Down Expand Up @@ -180,3 +244,122 @@ pub(crate) async fn spawn_child_async(

spawn_tokio_command_with_retry(&mut cmd).await
}

#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::SandboxPolicy;

#[cfg(unix)]
static STDIN_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());

#[cfg(unix)]
struct StdinRedirectGuard {
saved_stdin_fd: i32,
read_fd: i32,
write_fd: i32,
}

#[cfg(unix)]
impl StdinRedirectGuard {
fn install_pipe_as_stdin() -> Self {
let mut fds = [0; 2];
assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0, "pipe");
let saved_stdin_fd = unsafe { libc::dup(libc::STDIN_FILENO) };
assert!(saved_stdin_fd >= 0, "dup stdin");
assert_eq!(unsafe { libc::dup2(fds[0], libc::STDIN_FILENO) }, libc::STDIN_FILENO, "dup2 stdin");
Self {
saved_stdin_fd,
read_fd: fds[0],
write_fd: fds[1],
}
}
}

#[cfg(unix)]
impl Drop for StdinRedirectGuard {
fn drop(&mut self) {
unsafe {
let _ = libc::dup2(self.saved_stdin_fd, libc::STDIN_FILENO);
let _ = libc::close(self.saved_stdin_fd);
let _ = libc::close(self.read_fd);
let _ = libc::close(self.write_fd);
}
}
}

#[cfg(unix)]
#[test]
fn background_spawn_redirects_stdin_away_from_parent_terminal() {
let _guard = STDIN_GUARD.lock().expect("stdin test mutex");
let _stdin_guard = StdinRedirectGuard::install_pipe_as_stdin();

let mut cmd = std::process::Command::new("python3");
cmd.arg("-c")
.arg("import sys; data = sys.stdin.read(); print('eof' if data == '' else 'data')")
.stdout(Stdio::piped())
.stderr(Stdio::piped());

let mut child = spawn_background_command_with_retry(&mut cmd).expect("spawn background helper");
let deadline = std::time::Instant::now() + Duration::from_secs(2);
loop {
if let Some(_status) = child.try_wait().expect("poll child") {
break;
}
assert!(std::time::Instant::now() < deadline, "background helper should not block on inherited stdin");
std::thread::sleep(Duration::from_millis(10));
}

let output = child.wait_with_output().expect("wait with output");
assert!(output.status.success(), "child should exit successfully: {output:?}");
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "eof");
}

#[cfg(unix)]
#[test]
fn redirect_for_shell_tool_uses_new_session_strategy() {
assert_eq!(
unix_child_session_strategy(StdioPolicy::RedirectForShellTool),
UnixChildSessionStrategy::NewSession,
);
assert_eq!(
unix_child_session_strategy(StdioPolicy::Inherit),
UnixChildSessionStrategy::NewProcessGroup,
);
}

#[cfg(unix)]
#[tokio::test]
async fn redirect_for_shell_tool_detaches_from_controlling_tty() {
let parent_tty = match std::fs::OpenOptions::new().read(true).open("/dev/tty") {
Ok(tty) => tty,
Err(_) => return,
};

drop(parent_tty);

let child = spawn_child_async(
PathBuf::from("python3"),
vec![
"-c".to_string(),
"import os\ntry:\n os.open('/dev/tty', os.O_RDONLY)\n print('tty-present')\nexcept OSError as e:\n print(f'tty-missing:{e.errno}')".to_string(),
],
None,
std::env::current_dir().expect("cwd"),
&SandboxPolicy::DangerFullAccess,
StdioPolicy::RedirectForShellTool,
HashMap::new(),
)
.await
.expect("spawn shell tool child");

let output = child.wait_with_output().await.expect("wait for child");
assert!(output.status.success(), "child should exit successfully: {output:?}");

let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.trim_start().starts_with("tty-missing:"),
"shell tool child should not retain a controlling tty: {stdout:?}"
);
}
}
2 changes: 1 addition & 1 deletion code-rs/core/src/user_notification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ impl UserNotifier {
command.arg(json);

// Fire-and-forget – we do not wait for completion.
if let Err(e) = crate::spawn::spawn_std_command_with_retry(&mut command) {
if let Err(e) = crate::spawn::spawn_background_command_with_retry(&mut command) {
warn!("failed to spawn notifier '{}': {e}", notify_command[0]);
}
}
Expand Down
Loading