Codex Handbook
core/src/unified_exec/mod_tests.rs 932 lines
use super::head_tail_buffer::HeadTailBuffer;use super::*;use crate::codex_thread::BackgroundTerminalInfo;use crate::exec::ExecCapturePolicy;use crate::exec::ExecExpiration;use crate::sandboxing::ExecRequest;use crate::session::session::Session;use crate::session::tests::make_session_and_context;use crate::session::turn_context::TurnContext;use crate::tools::context::ExecCommandToolOutput;use crate::unified_exec::WriteStdinRequest;use crate::unified_exec::process::OutputHandles;use codex_exec_server::ExecProcess;use codex_exec_server::ExecProcessEventReceiver;use codex_exec_server::ExecProcessFuture;use codex_exec_server::ProcessId;use codex_exec_server::ProcessSignal;use codex_exec_server::ReadResponse;use codex_exec_server::StartedExecProcess;use codex_exec_server::WriteResponse;use codex_exec_server::WriteStatus;use codex_sandboxing::SandboxType;use codex_utils_output_truncation::TruncationPolicy;use codex_utils_output_truncation::approx_token_count;use core_test_support::get_remote_test_env;use core_test_support::skip_if_sandbox;use core_test_support::test_codex::test_env as remote_test_env;use pretty_assertions::assert_eq;use std::collections::HashMap;use std::path::PathBuf;use std::sync::Arc;use tokio::sync::Notify;use tokio::sync::watch;use tokio::time::Duration;use tokio::time::Instant;async fn test_session_and_turn() -> (Arc<Session>, Arc<TurnContext>) {    let (session, turn) = make_session_and_context().await;    (Arc::new(session), Arc::new(turn))}async fn exec_command(    session: &Arc<Session>,    turn: &Arc<TurnContext>,    cmd: &str,    yield_time_ms: u64,    workdir: Option<PathBuf>,) -> Result<ExecCommandToolOutput, UnifiedExecError> {    exec_command_with_tty(        session,        turn,        cmd,        yield_time_ms,        workdir,        /*tty*/ true,    )    .await}fn shell_env() -> HashMap<String, String> {    std::env::vars().collect()}fn test_exec_request(    turn: &TurnContext,    command: Vec<String>,    cwd: AbsolutePathBuf,    env: HashMap<String, String>,) -> ExecRequest {    let windows_sandbox_private_desktop = false;    let permission_profile = turn.permission_profile();    let network = None;    let arg0 = None;    ExecRequest::new(        command,        cwd,        env,        network,        ExecExpiration::DefaultTimeout,        ExecCapturePolicy::ShellTool,        SandboxType::None,        turn.config.effective_workspace_roots(),        turn.windows_sandbox_level,        windows_sandbox_private_desktop,        permission_profile,        arg0,    )}async fn exec_command_with_tty(    session: &Arc<Session>,    turn: &Arc<TurnContext>,    cmd: &str,    yield_time_ms: u64,    workdir: Option<PathBuf>,    tty: bool,) -> Result<ExecCommandToolOutput, UnifiedExecError> {    let manager = &session.services.unified_exec_manager;    let process_id = manager.allocate_process_id().await;    #[allow(deprecated)]    let cwd = workdir        .as_ref()        .map_or_else(|| turn.cwd.clone(), |workdir| turn.cwd.join(workdir));    let command = vec!["bash".to_string(), "-lc".to_string(), cmd.to_string()];    let request = test_exec_request(turn, command.clone(), cwd.clone(), shell_env());    let process = Arc::new(        manager            .open_session_with_exec_env(                process_id,                &request,                tty,                Box::new(NoopSpawnLifecycle),                turn.environments                    .primary()                    .expect("turn environment")                    .environment                    .as_ref(),            )            .await?,    );    let context =        UnifiedExecContext::new(Arc::clone(session), Arc::clone(turn), "call".to_string());    let started_at = Instant::now();    let process_started_alive = !process.has_exited() && process.exit_code().is_none();    if process_started_alive {        let entry = ProcessEntry {            process: Arc::clone(&process),            call_id: context.call_id.clone(),            process_id,            cwd: cwd.clone(),            initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(true)),            hook_command: cmd.to_string(),            tty,            network_approval: None,            session: Arc::downgrade(session),            last_used: started_at,        };        manager            .process_store            .lock()            .await            .processes            .insert(process_id, entry);    }    let OutputHandles {        output_buffer,        output_notify,        output_closed,        output_closed_notify,        cancellation_token,    } = process.output_handles();    let deadline = started_at + Duration::from_millis(yield_time_ms);    let collected = UnifiedExecProcessManager::collect_output_until_deadline(        &output_buffer,        &output_notify,        &output_closed,        &output_closed_notify,        &cancellation_token,        Some(session.subscribe_out_of_band_elicitation_pause_state()),        deadline,    )    .await;    let wall_time = Instant::now().saturating_duration_since(started_at);    let text = String::from_utf8_lossy(&collected).to_string();    let has_exited = process.has_exited();    let exit_code = process.exit_code();    let response_process_id = if process_started_alive && !has_exited {        Some(process_id)    } else {        manager.release_process_id(process_id).await;        None    };    if response_process_id.is_some()        && let Some(entry) = manager            .process_store            .lock()            .await            .processes            .get_mut(&process_id)    {        entry            .initial_exec_command_active            .store(false, std::sync::atomic::Ordering::Release);    }    Ok(ExecCommandToolOutput {        event_call_id: context.call_id,        chunk_id: generate_chunk_id(),        wall_time,        raw_output: collected,        truncation_policy: turn.truncation_policy,        max_output_tokens: None,        process_id: response_process_id,        exit_code,        original_token_count: Some(approx_token_count(&text)),        hook_command: Some(cmd.to_string()),    })}#[derive(Debug)]struct TestSpawnLifecycle {    inherited_fds: Vec<i32>,}impl SpawnLifecycle for TestSpawnLifecycle {    fn inherited_fds(&self) -> Vec<i32> {        self.inherited_fds.clone()    }}struct BlockingTerminateExecProcess {    process_id: ProcessId,    terminate_started: watch::Sender<bool>,    allow_terminate: Arc<Notify>,    wake_tx: watch::Sender<u64>,}impl BlockingTerminateExecProcess {    async fn read(&self) -> Result<ReadResponse, codex_exec_server::ExecServerError> {        Ok(ReadResponse {            chunks: Vec::new(),            next_seq: 1,            exited: false,            exit_code: None,            closed: false,            failure: None,        })    }    async fn write(&self) -> Result<WriteResponse, codex_exec_server::ExecServerError> {        Ok(WriteResponse {            status: WriteStatus::Accepted,        })    }    async fn terminate(&self) -> Result<(), codex_exec_server::ExecServerError> {        let _ = self.terminate_started.send(true);        self.allow_terminate.notified().await;        Ok(())    }}impl ExecProcess for BlockingTerminateExecProcess {    fn process_id(&self) -> &ProcessId {        &self.process_id    }    fn subscribe_wake(&self) -> watch::Receiver<u64> {        self.wake_tx.subscribe()    }    fn subscribe_events(&self) -> ExecProcessEventReceiver {        ExecProcessEventReceiver::empty()    }    fn read(        &self,        _after_seq: Option<u64>,        _max_bytes: Option<usize>,        _wait_ms: Option<u64>,    ) -> ExecProcessFuture<'_, ReadResponse> {        Box::pin(BlockingTerminateExecProcess::read(self))    }    fn write(&self, _chunk: Vec<u8>) -> ExecProcessFuture<'_, WriteResponse> {        Box::pin(BlockingTerminateExecProcess::write(self))    }    fn signal(&self, _signal: ProcessSignal) -> ExecProcessFuture<'_, ()> {        Box::pin(async { Ok(()) })    }    fn terminate(&self) -> ExecProcessFuture<'_, ()> {        Box::pin(BlockingTerminateExecProcess::terminate(self))    }}async fn blocking_terminate_unified_process(    process_id: i32,    terminate_started: watch::Sender<bool>,    allow_terminate: Arc<Notify>,) -> anyhow::Result<Arc<UnifiedExecProcess>> {    let (wake_tx, _wake_rx) = watch::channel(0);    Ok(Arc::new(        UnifiedExecProcess::from_exec_server_started(            StartedExecProcess {                process: Arc::new(BlockingTerminateExecProcess {                    process_id: process_id.to_string().into(),                    terminate_started,                    allow_terminate,                    wake_tx,                }),            },            SandboxType::None,        )        .await?,    ))}async fn write_stdin(    session: &Arc<Session>,    process_id: i32,    input: &str,    yield_time_ms: u64,) -> Result<ExecCommandToolOutput, UnifiedExecError> {    session        .services        .unified_exec_manager        .write_stdin(WriteStdinRequest {            process_id,            input,            yield_time_ms,            max_output_tokens: None,            truncation_policy: TruncationPolicy::Tokens(10_000),        })        .await}#[test]fn push_chunk_preserves_prefix_and_suffix() {    let mut buffer = HeadTailBuffer::default();    buffer.push_chunk(vec![b'a'; UNIFIED_EXEC_OUTPUT_MAX_BYTES]);    buffer.push_chunk(vec![b'b']);    buffer.push_chunk(vec![b'c']);    assert_eq!(buffer.retained_bytes(), UNIFIED_EXEC_OUTPUT_MAX_BYTES);    let snapshot = buffer.snapshot_chunks();    let first = snapshot.first().expect("expected at least one chunk");    assert_eq!(first.first(), Some(&b'a'));    assert!(snapshot.iter().any(|chunk| chunk.as_slice() == b"b"));    assert_eq!(        snapshot            .last()            .expect("expected at least one chunk")            .as_slice(),        b"c"    );}#[test]fn head_tail_buffer_default_preserves_prefix_and_suffix() {    let mut buffer = HeadTailBuffer::default();    buffer.push_chunk(vec![b'a'; UNIFIED_EXEC_OUTPUT_MAX_BYTES]);    buffer.push_chunk(b"bc".to_vec());    let rendered = buffer.to_bytes();    assert_eq!(rendered.first(), Some(&b'a'));    assert!(rendered.ends_with(b"bc"));}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn unified_exec_persists_across_requests() -> anyhow::Result<()> {    skip_if_sandbox!(Ok(()));    let (session, turn) = test_session_and_turn().await;    #[allow(deprecated)]    let cwd = turn.cwd.clone();    let open_shell = exec_command(        &session, &turn, "bash -i", /*yield_time_ms*/ 2_500, /*workdir*/ None,    )    .await?;    let process_id = open_shell.process_id.expect("expected process_id");    assert_eq!(        session.list_background_terminals().await,        vec![BackgroundTerminalInfo {            item_id: "call".to_string(),            process_id: process_id.to_string(),            command: "bash -i".to_string(),            cwd,        }]    );    write_stdin(        &session,        process_id,        "export CODEX_INTERACTIVE_SHELL_VAR=codex\n",        /*yield_time_ms*/ 2_500,    )    .await?;    let out_2 = write_stdin(        &session,        process_id,        "echo $CODEX_INTERACTIVE_SHELL_VAR\n",        /*yield_time_ms*/ 2_500,    )    .await?;    assert!(        out_2            .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS)            .contains("codex"),        "expected environment variable output"    );    assert!(session.terminate_background_terminal(process_id).await);    assert!(!session.terminate_background_terminal(process_id).await);    assert!(session.list_background_terminals().await.is_empty());    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn multi_unified_exec_sessions() -> anyhow::Result<()> {    skip_if_sandbox!(Ok(()));    let (session, turn) = test_session_and_turn().await;    let shell_a = exec_command(        &session, &turn, "bash -i", /*yield_time_ms*/ 2_500, /*workdir*/ None,    )    .await?;    let session_a = shell_a.process_id.expect("expected process id");    write_stdin(        &session,        session_a,        "export CODEX_INTERACTIVE_SHELL_VAR=codex\n",        /*yield_time_ms*/ 2_500,    )    .await?;    let out_2 = exec_command(        &session,        &turn,        "echo $CODEX_INTERACTIVE_SHELL_VAR",        /*yield_time_ms*/ 2_500,        /*workdir*/ None,    )    .await?;    tokio::time::sleep(Duration::from_secs(2)).await;    assert!(        out_2.process_id.is_none(),        "short command should not report a process id if it exits quickly"    );    assert!(        !out_2            .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS)            .contains("codex"),        "short command should run in a fresh shell"    );    let out_3 = write_stdin(        &session,        shell_a.process_id.expect("expected process id"),        "echo $CODEX_INTERACTIVE_SHELL_VAR\n",        /*yield_time_ms*/ 2_500,    )    .await?;    assert!(        out_3            .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS)            .contains("codex"),        "session should preserve state"    );    Ok(())}#[tokio::test]async fn unified_exec_timeouts() -> anyhow::Result<()> {    skip_if_sandbox!(Ok(()));    const TEST_VAR_VALUE: &str = "unified_exec_var_123";    let (session, turn) = test_session_and_turn().await;    let open_shell = exec_command(        &session, &turn, "bash -i", /*yield_time_ms*/ 2_500, /*workdir*/ None,    )    .await?;    let process_id = open_shell.process_id.expect("expected process id");    write_stdin(        &session,        process_id,        format!("export CODEX_INTERACTIVE_SHELL_VAR={TEST_VAR_VALUE}\n").as_str(),        /*yield_time_ms*/ 2_500,    )    .await?;    let out_2 = write_stdin(        &session,        process_id,        "sleep 5 && echo $CODEX_INTERACTIVE_SHELL_VAR\n",        /*yield_time_ms*/ 10,    )    .await?;    assert!(        !out_2            .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS)            .contains(TEST_VAR_VALUE),        "timeout too short should yield incomplete output"    );    tokio::time::sleep(Duration::from_secs(7)).await;    let out_3 = write_stdin(&session, process_id, "", /*yield_time_ms*/ 100).await?;    assert!(        out_3            .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS)            .contains(TEST_VAR_VALUE),        "subsequent poll should retrieve output"    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn unified_exec_pause_blocks_yield_timeout() -> anyhow::Result<()> {    skip_if_sandbox!(Ok(()));    let (session, turn) = test_session_and_turn().await;    session.set_out_of_band_elicitation_pause_state(/*paused*/ true);    let paused_session = Arc::clone(&session);    tokio::spawn(async move {        tokio::time::sleep(Duration::from_secs(2)).await;        paused_session.set_out_of_band_elicitation_pause_state(/*paused*/ false);    });    let started = tokio::time::Instant::now();    let response = exec_command(        &session,        &turn,        "sleep 1 && echo unified-exec-done",        /*yield_time_ms*/ 250,        /*workdir*/ None,    )    .await?;    assert!(        started.elapsed() >= Duration::from_secs(2),        "pause should block the unified exec yield timeout"    );    assert!(        response            .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS)            .contains("unified-exec-done"),        "exec_command should wait for output after the pause lifts"    );    assert!(        response.process_id.is_none(),        "completed command should not leave a background process"    );    Ok(())}#[tokio::test]#[ignore] // Ignored while we have a better way to test this.async fn requests_with_large_timeout_are_capped() -> anyhow::Result<()> {    let (session, turn) = test_session_and_turn().await;    let result = exec_command(        &session,        &turn,        "echo codex",        /*yield_time_ms*/ 120_000,        /*workdir*/ None,    )    .await?;    assert!(result.process_id.is_some());    assert!(        result            .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS)            .contains("codex")    );    Ok(())}#[tokio::test]#[ignore] // Ignored while we have a better way to test this.async fn completed_commands_do_not_persist_sessions() -> anyhow::Result<()> {    let (session, turn) = test_session_and_turn().await;    let result = exec_command(        &session,        &turn,        "echo codex",        /*yield_time_ms*/ 2_500,        /*workdir*/ None,    )    .await?;    assert!(        result.process_id.is_some(),        "completed command should report a process id"    );    assert!(        result            .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS)            .contains("codex")    );    assert!(        session            .services            .unified_exec_manager            .process_store            .lock()            .await            .processes            .is_empty()    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn reusing_completed_process_returns_unknown_process() -> anyhow::Result<()> {    skip_if_sandbox!(Ok(()));    let (session, turn) = test_session_and_turn().await;    let open_shell = exec_command(        &session, &turn, "bash -i", /*yield_time_ms*/ 2_500, /*workdir*/ None,    )    .await?;    let process_id = open_shell.process_id.expect("expected process id");    write_stdin(&session, process_id, "exit\n", /*yield_time_ms*/ 2_500).await?;    tokio::time::sleep(Duration::from_millis(200)).await;    let err = write_stdin(&session, process_id, "", /*yield_time_ms*/ 100)        .await        .expect_err("expected unknown process error");    match err {        UnifiedExecError::UnknownProcessId { process_id: err_id } => {            assert_eq!(err_id, process_id, "process id should match request");        }        other => panic!("expected UnknownProcessId, got {other:?}"),    }    assert!(        session            .services            .unified_exec_manager            .process_store            .lock()            .await            .processes            .is_empty()    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn terminating_initial_exec_command_rechecks_initial_response_state() -> anyhow::Result<()> {    let (session, turn) = test_session_and_turn().await;    let manager = &session.services.unified_exec_manager;    let process_id = manager.allocate_process_id().await;    let (terminate_started_tx, mut terminate_started_rx) = watch::channel(false);    let allow_terminate = Arc::new(Notify::new());    let process = blocking_terminate_unified_process(        process_id,        terminate_started_tx,        Arc::clone(&allow_terminate),    )    .await?;    #[allow(deprecated)]    let cwd = turn.cwd.clone();    manager.process_store.lock().await.processes.insert(        process_id,        ProcessEntry {            process,            call_id: "call".to_string(),            process_id,            cwd,            initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(true)),            hook_command: "sleep 60".to_string(),            tty: true,            network_approval: None,            session: Arc::downgrade(&session),            last_used: Instant::now(),        },    );    let terminate_task = tokio::spawn({        let session = Arc::clone(&session);        async move { session.terminate_background_terminal(process_id).await }    });    tokio::time::timeout(        Duration::from_secs(2),        terminate_started_rx.wait_for(|started| *started),    )    .await    .expect("terminate should start")    .expect("terminate signal sender should stay open");    {        let mut store = manager.process_store.lock().await;        let entry = store            .processes            .get_mut(&process_id)            .expect("process should remain stored until initial response returns");        entry            .initial_exec_command_active            .store(false, std::sync::atomic::Ordering::Release);    }    allow_terminate.notify_waiters();    let terminated = tokio::time::timeout(Duration::from_secs(2), terminate_task)        .await        .expect("terminate should finish")        .expect("terminate task should not panic");    assert!(terminated);    assert!(        !manager            .process_store            .lock()            .await            .processes            .contains_key(&process_id)    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn terminating_during_stdin_poll_returns_exited_response() -> anyhow::Result<()> {    let (session, turn) = test_session_and_turn().await;    let manager = &session.services.unified_exec_manager;    let process_id = manager.allocate_process_id().await;    let (terminate_started_tx, _terminate_started_rx) = watch::channel(false);    let allow_terminate = Arc::new(Notify::new());    let process = blocking_terminate_unified_process(        process_id,        terminate_started_tx,        Arc::clone(&allow_terminate),    )    .await?;    #[allow(deprecated)]    let cwd = turn.cwd.clone();    let last_used = Instant::now() - Duration::from_secs(1);    manager.process_store.lock().await.processes.insert(        process_id,        ProcessEntry {            process: Arc::clone(&process),            call_id: "call".to_string(),            process_id,            cwd,            initial_exec_command_active: Arc::new(std::sync::atomic::AtomicBool::new(false)),            hook_command: "sleep 60".to_string(),            tty: true,            network_approval: None,            session: Arc::downgrade(&session),            last_used,        },    );    let poll_task = tokio::spawn({        let session = Arc::clone(&session);        async move {            write_stdin(&session, process_id, "", /*yield_time_ms*/ 60_000).await        }    });    tokio::time::timeout(Duration::from_secs(2), async {        loop {            let poll_started = manager                .process_store                .lock()                .await                .processes                .get(&process_id)                .is_some_and(|entry| entry.last_used != last_used);            if poll_started {                break;            }            tokio::time::sleep(Duration::from_millis(10)).await;        }    })    .await    .expect("poll should clone process handles");    manager.release_process_id(process_id).await;    allow_terminate.notify_one();    process.terminate_confirmed().await?;    let output = tokio::time::timeout(Duration::from_secs(2), poll_task)        .await        .expect("poll should finish")        .expect("poll task should not panic")?;    assert_eq!(output.process_id, None);    assert!(manager.process_store.lock().await.processes.is_empty());    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn completed_pipe_commands_preserve_exit_code() -> anyhow::Result<()> {    let (_, turn) = make_session_and_context().await;    #[allow(deprecated)]    let cwd = turn.cwd.clone();    let request = test_exec_request(        &turn,        vec!["bash".to_string(), "-lc".to_string(), "exit 17".to_string()],        cwd,        shell_env(),    );    let environment = codex_exec_server::Environment::default_for_tests();    let process = UnifiedExecProcessManager::default()        .open_session_with_exec_env(            /*process_id*/ 1234,            &request,            /*tty*/ false,            Box::new(NoopSpawnLifecycle),            &environment,        )        .await?;    if !process.has_exited() {        let exit_signal = process.cancellation_token();        assert!(            tokio::time::timeout(Duration::from_secs(2), exit_signal.cancelled())                .await                .is_ok(),            "process did not report exit within timeout"        );    }    assert!(process.has_exited());    assert_eq!(process.exit_code(), Some(17));    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn unified_exec_uses_remote_exec_server_when_configured() -> anyhow::Result<()> {    skip_if_sandbox!(Ok(()));    let Some(_remote_env) = get_remote_test_env() else {        return Ok(());    };    let remote_test_env = remote_test_env().await?;    let (_, turn) = make_session_and_context().await;    let request = test_exec_request(        &turn,        vec!["bash".to_string(), "-i".to_string()],        remote_test_env.cwd().clone(),        shell_env(),    );    let manager = UnifiedExecProcessManager::default();    let process = manager        .open_session_with_exec_env(            /*process_id*/ 1234,            &request,            /*tty*/ true,            Box::new(NoopSpawnLifecycle),            remote_test_env.environment(),        )        .await?;    process.write(b"printf 'remote-unified-exec\\n'\n").await?;    tokio::time::sleep(Duration::from_millis(100)).await;    let crate::unified_exec::process::OutputHandles {        output_buffer,        output_notify,        output_closed,        output_closed_notify,        cancellation_token,    } = process.output_handles();    let collected = UnifiedExecProcessManager::collect_output_until_deadline(        &output_buffer,        &output_notify,        &output_closed,        &output_closed_notify,        &cancellation_token,        /*pause_state*/ None,        Instant::now() + Duration::from_millis(2_500),    )    .await;    assert!(String::from_utf8_lossy(&collected).contains("remote-unified-exec"));    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn remote_exec_server_rejects_inherited_fd_launches() -> anyhow::Result<()> {    skip_if_sandbox!(Ok(()));    let Some(_remote_env) = get_remote_test_env() else {        return Ok(());    };    let remote_test_env = remote_test_env().await?;    let (_, mut turn) = make_session_and_context().await;    turn.environments.turn_environments[0].environment =        Arc::new(remote_test_env.environment().clone());    #[allow(deprecated)]    let cwd = turn.cwd.clone();    let request = test_exec_request(        &turn,        vec!["bash".to_string(), "-lc".to_string(), "echo ok".to_string()],        cwd,        shell_env(),    );    let manager = UnifiedExecProcessManager::default();    let err = manager        .open_session_with_exec_env(            /*process_id*/ 1234,            &request,            /*tty*/ true,            Box::new(TestSpawnLifecycle {                inherited_fds: vec![42],            }),            turn.environments                .primary()                .expect("turn environment")                .environment                .as_ref(),        )        .await        .expect_err("expected inherited fd rejection");    assert_eq!(        err.to_string(),        "Failed to create unified exec process: remote exec-server does not support inherited file descriptors"    );    Ok(())}