mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
## Why Failed shell snapshot captures could leave processes launched by shell startup files running after the capture ended. ## What changed Run each Unix shell snapshot capture in its own process group and kill that group on failure, timeout, or cancellation. Keep the capture leader unreaped until output and exit status are validated so its process group ID cannot be reused during cleanup. Preserve startup helpers after a successful capture. ## Testing Add regression coverage for startup child cleanup after nonzero exits, invalid output, cancellation, and timeout, alongside fallback and retry behavior. Verify that successful captures preserve delayed startup output and background services and reuse the cached snapshot. GitOrigin-RevId: a56ef5d8615325952bdc8bb78727de1559e7e9c7
1903 lines
70 KiB
Rust
1903 lines
70 KiB
Rust
mod common;
|
|
#[cfg(unix)]
|
|
#[path = "exec_process/shell_snapshot.rs"]
|
|
mod shell_snapshot;
|
|
#[path = "exec_process/windows_sandbox.rs"]
|
|
mod windows_sandbox;
|
|
|
|
use std::collections::HashMap;
|
|
#[cfg(unix)]
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use codex_exec_server::Environment;
|
|
use codex_exec_server::ExecBackend;
|
|
#[cfg(unix)]
|
|
use codex_exec_server::ExecEnvPolicy;
|
|
use codex_exec_server::ExecOutputStream;
|
|
use codex_exec_server::ExecParams;
|
|
use codex_exec_server::ExecProcess;
|
|
use codex_exec_server::ExecProcessEvent;
|
|
#[cfg(any(unix, windows))]
|
|
use codex_exec_server::FileSystemSandboxContext;
|
|
use codex_exec_server::ProcessId;
|
|
use codex_exec_server::ProcessSignal;
|
|
use codex_exec_server::ReadResponse;
|
|
#[cfg(unix)]
|
|
use codex_exec_server::ShellInfo;
|
|
#[cfg(unix)]
|
|
use codex_exec_server::ShellSnapshotRequest;
|
|
use codex_exec_server::StartedExecProcess;
|
|
#[cfg(any(unix, windows))]
|
|
use codex_exec_server::WindowsSandboxSelection;
|
|
use codex_exec_server::WriteStatus;
|
|
#[cfg(unix)]
|
|
use codex_network_proxy::NetworkProxyConfig;
|
|
#[cfg(unix)]
|
|
use codex_network_proxy::RemoteNetworkProxyConfig;
|
|
#[cfg(unix)]
|
|
use codex_network_proxy::RemoteNetworkProxyLaunchConfig;
|
|
#[cfg(unix)]
|
|
use codex_protocol::config_types::ShellEnvironmentPolicyInherit;
|
|
#[cfg(unix)]
|
|
use codex_protocol::models::PermissionProfile;
|
|
#[cfg(unix)]
|
|
use codex_protocol::permissions::FileSystemAccessMode;
|
|
#[cfg(unix)]
|
|
use codex_protocol::permissions::FileSystemPath;
|
|
#[cfg(unix)]
|
|
use codex_protocol::permissions::FileSystemSandboxEntry;
|
|
#[cfg(unix)]
|
|
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
|
#[cfg(unix)]
|
|
use codex_protocol::permissions::FileSystemSpecialPath;
|
|
#[cfg(unix)]
|
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
|
use codex_protocol::protocol::SandboxPolicy;
|
|
use codex_utils_path_uri::PathUri;
|
|
use pretty_assertions::assert_eq;
|
|
use tempfile::TempDir;
|
|
use test_case::test_case;
|
|
use tokio::sync::watch;
|
|
use tokio::time::Duration;
|
|
use tokio::time::sleep;
|
|
use tokio::time::timeout;
|
|
|
|
use common::DELAYED_OUTPUT_AFTER_EXIT_PARENT_ARG;
|
|
use common::current_test_binary_helper_paths;
|
|
use common::exec_server::ExecServerHarness;
|
|
use common::exec_server::exec_server;
|
|
|
|
struct ProcessContext {
|
|
backend: Arc<dyn ExecBackend>,
|
|
_server: Option<ExecServerHarness>,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum ProcessEventSnapshot {
|
|
Output {
|
|
seq: u64,
|
|
stream: ExecOutputStream,
|
|
text: String,
|
|
},
|
|
Exited {
|
|
seq: u64,
|
|
exit_code: i32,
|
|
},
|
|
Closed {
|
|
seq: u64,
|
|
},
|
|
}
|
|
|
|
async fn create_process_context(use_remote: bool) -> Result<ProcessContext> {
|
|
if use_remote {
|
|
let server = exec_server().await?;
|
|
let environment = Environment::create_for_tests(Some(server.websocket_url().to_string()))?;
|
|
Ok(ProcessContext {
|
|
backend: environment.get_exec_backend(),
|
|
_server: Some(server),
|
|
})
|
|
} else {
|
|
let environment = Environment::create_for_tests(/*exec_server_url*/ None)?;
|
|
Ok(ProcessContext {
|
|
backend: environment.get_exec_backend(),
|
|
_server: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn codex_home_symlink_opt_out_respects_host_config_and_scope() -> Result<()> {
|
|
use codex_exec_server::WriteFileOptions;
|
|
use common::exec_server::exec_server_with_env;
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let workspace = TempDir::new()?;
|
|
let home = TempDir::new()?;
|
|
let target = TempDir::new()?;
|
|
let alias = home.path().join("visualizations");
|
|
let other_alias = workspace.path().join(".codex/visualizations");
|
|
std::fs::create_dir(workspace.path().join(".codex"))?;
|
|
symlink(target.path(), &alias)?;
|
|
symlink(target.path(), &other_alias)?;
|
|
std::fs::write(
|
|
workspace.path().join(".codex/config.toml"),
|
|
"allow_symlinked_codex_home = true\n",
|
|
)?;
|
|
|
|
for enabled in [None, Some(false), Some(true)] {
|
|
std::fs::write(
|
|
home.path().join("config.toml"),
|
|
enabled.map_or_else(String::new, |enabled| {
|
|
format!("allow_symlinked_codex_home = {enabled}\n")
|
|
}),
|
|
)?;
|
|
let mut server = exec_server_with_env([("CODEX_HOME", home.path())], &[]).await?;
|
|
let environment = Environment::create_for_tests(Some(server.websocket_url().to_string()))?;
|
|
for root in [alias.as_path(), other_alias.as_path(), workspace.path()] {
|
|
let mut policy = FileSystemSandboxPolicy::read_only();
|
|
policy.entries.push(FileSystemSandboxEntry::new(
|
|
PathUri::from_host_native_path(root)?.into(),
|
|
FileSystemAccessMode::Write,
|
|
));
|
|
let sandbox = FileSystemSandboxContext::from_permission_profile(
|
|
PermissionProfile::from_runtime_permissions(
|
|
&policy,
|
|
NetworkSandboxPolicy::Restricted,
|
|
),
|
|
PathUri::from_host_native_path(workspace.path())?,
|
|
);
|
|
let result = environment
|
|
.get_filesystem()
|
|
.write_file(
|
|
&PathUri::from_host_native_path(root.join("output"))?,
|
|
b"written".to_vec(),
|
|
WriteFileOptions::default(),
|
|
Some(&sandbox),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
result.is_ok(),
|
|
root == workspace.path() || (enabled == Some(true) && root == alias),
|
|
"root={root:?}, enabled={enabled:?}: {result:?}"
|
|
);
|
|
if let Err(error) = result {
|
|
assert!(
|
|
error
|
|
.to_string()
|
|
.contains("symlinked writable roots are not supported"),
|
|
"{error}"
|
|
);
|
|
}
|
|
}
|
|
server.shutdown().await?;
|
|
}
|
|
assert_eq!(std::fs::read(target.path().join("output"))?, b"written");
|
|
assert_eq!(std::fs::read(workspace.path().join("output"))?, b"written");
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test_case(false, false, false, false, "bash"; "local_pipe")]
|
|
#[test_case(false, true, false, false, "bash"; "local_tty")]
|
|
#[test_case(true, false, false, false, "bash"; "remote_pipe")]
|
|
#[test_case(true, true, false, false, "bash"; "remote_tty")]
|
|
#[test_case(true, false, true, false, "bash"; "remote_sandbox")]
|
|
#[test_case(false, false, false, false, "sh"; "local_sh_pipe")]
|
|
#[test_case(false, false, false, false, "bash-sh"; "local_bash_backed_sh")]
|
|
#[test_case(false, false, false, true, "bash"; "local_bash_env")]
|
|
#[test_case(true, false, false, true, "bash"; "remote_bash_env")]
|
|
#[cfg_attr(
|
|
target_os = "macos",
|
|
test_case(false, false, false, false, "zsh"; "local_zsh_pipe")
|
|
)]
|
|
#[cfg_attr(
|
|
target_os = "macos",
|
|
test_case(false, false, false, true, "zsh"; "local_zshenv")
|
|
)]
|
|
#[cfg_attr(
|
|
target_os = "macos",
|
|
test_case(true, false, false, true, "zsh"; "remote_zshenv")
|
|
)]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
// Serialize tests that launch a real exec-server process through the full CLI.
|
|
#[serial_test::serial(remote_exec_server)]
|
|
async fn shell_snapshot_v2_filters_profile_exports_and_stays_in_memory(
|
|
use_remote: bool,
|
|
tty: bool,
|
|
use_sandbox: bool,
|
|
automatic_startup: bool,
|
|
shell_name: &str,
|
|
) -> Result<()> {
|
|
if use_sandbox
|
|
&& let Some(warning) =
|
|
codex_sandboxing::system_bwrap_warning(&PermissionProfile::read_only())
|
|
{
|
|
eprintln!("skipping sandbox test: {warning}");
|
|
return Ok(());
|
|
}
|
|
let context = create_process_context(use_remote).await?;
|
|
let home = TempDir::new()?;
|
|
let cwd = PathUri::from_host_native_path(home.path())?;
|
|
let (shell_path, profile_name) = match shell_name {
|
|
"bash" if automatic_startup => ("/bin/bash", ".bash-env"),
|
|
"bash" => ("/bin/bash", ".bashrc"),
|
|
"sh" => ("/bin/sh", ".snapshot-env"),
|
|
"bash-sh" => ("/bin/bash", ".snapshot-env"),
|
|
"zsh" if automatic_startup => ("/bin/zsh", ".zshenv"),
|
|
"zsh" => ("/bin/zsh", ".zshrc"),
|
|
name => anyhow::bail!("unsupported test shell {name}"),
|
|
};
|
|
let profile_path = home.path().join(profile_name);
|
|
let profile_path_entry = home.path().join("profile-bin");
|
|
let runtime_path_entry = home.path().join("runtime-bin");
|
|
std::fs::create_dir(&profile_path_entry)?;
|
|
let wc = profile_path_entry.join("wc");
|
|
std::fs::write(
|
|
&wc,
|
|
"#!/bin/sh\nprintf x >> \"$HOME/tool-captures\"\nexec /usr/bin/wc \"$@\"\n",
|
|
)?;
|
|
std::fs::set_permissions(&wc, std::fs::Permissions::from_mode(0o755))?;
|
|
let posix_shell = matches!(shell_name, "sh" | "bash-sh");
|
|
let padding = if !use_remote && !tty && shell_name == "bash" {
|
|
format!(
|
|
"snapshot_padding() {{ printf '%s' '{}'; }}\n",
|
|
"🦀".repeat(20_000)
|
|
)
|
|
} else {
|
|
String::new()
|
|
};
|
|
let shadowed_builtins = if posix_shell {
|
|
""
|
|
} else {
|
|
"unset() { exit 41; }\nbuiltin() { exit 41; }\nexec() { exit 41; }\n"
|
|
};
|
|
std::fs::write(
|
|
&profile_path,
|
|
format!(
|
|
"printf x >> \"$HOME/captures\"\nexport PATH=\"$HOME/profile-bin:/usr/bin:/bin\"\nexport PROFILE_ALLOWED=profile\nexport PROFILE_SECRET=secret\nexport PROFILE_DENIED=denied\nprofile_helper() {{ printf helper; }}\nif [ -n \"${{BASH_VERSION-}}\" ]; then\n shopt -s extglob nocasematch\n eval 'profile_helper() {{ case $1 in @(foo|bar)*) printf helper ;; *) return 1 ;; esac; }}'\nfi\nset -u\n{shadowed_builtins}{padding}"
|
|
),
|
|
)?;
|
|
if shell_name == "zsh" && automatic_startup {
|
|
std::fs::write(
|
|
home.path().join(".zshrc"),
|
|
"export PATH=\"$HOME/profile-bin:/usr/bin:/bin\"\n",
|
|
)?;
|
|
}
|
|
let mut configured_environment = HashMap::from([(
|
|
"HOME".to_string(),
|
|
home.path().to_string_lossy().into_owned(),
|
|
)]);
|
|
if posix_shell {
|
|
configured_environment.insert(
|
|
"ENV".to_string(),
|
|
"${XDG_CONFIG_HOME:-$HOME}/.snapshot-env".to_string(),
|
|
);
|
|
// Keep coverage for large values alongside the many-small-entry case below.
|
|
for index in 0..3 {
|
|
configured_environment.insert(format!("PROFILE_SDK_{index}"), "x".repeat(60 * 1024));
|
|
}
|
|
}
|
|
if shell_name == "bash" && automatic_startup {
|
|
configured_environment.insert(
|
|
"BASH_ENV".to_string(),
|
|
profile_path.to_string_lossy().into_owned(),
|
|
);
|
|
}
|
|
// Many small entries exercise capture overhead separately from the byte limit above.
|
|
let many_entries = !use_remote && !tty && !automatic_startup;
|
|
if many_entries {
|
|
configured_environment.extend(
|
|
(0..1_000).map(|index| (format!("PROFILE_ENTRY_{index}"), format!("value-{index}"))),
|
|
);
|
|
}
|
|
let policy = ExecEnvPolicy {
|
|
inherit: ShellEnvironmentPolicyInherit::All,
|
|
ignore_default_excludes: false,
|
|
exclude: vec!["PROFILE_DENIED".to_string()],
|
|
r#set: configured_environment,
|
|
include_only: vec![
|
|
"BASH_ENV".to_string(),
|
|
"ENV".to_string(),
|
|
"HOME".to_string(),
|
|
"PATH".to_string(),
|
|
"PROFILE_*".to_string(),
|
|
],
|
|
};
|
|
let (command_prefix, expected_prefix) = if shell_name == "sh" {
|
|
("", "")
|
|
} else {
|
|
("profile_helper FOObar; ", "helper")
|
|
};
|
|
let entry_check = if many_entries {
|
|
"[ \"${PROFILE_ENTRY_999-missing}\" = value-999 ] || exit 43; "
|
|
} else {
|
|
""
|
|
};
|
|
let command = format!(
|
|
"case $- in *u*) ;; *) exit 42 ;; esac; {entry_check}export PATH='{}':\"$PATH\"; {command_prefix}printf '|%s|%s|%s|%s|%s|%s' \"$PROFILE_ALLOWED\" \"${{PROFILE_SECRET-missing}}\" \"${{PROFILE_DENIED-missing}}\" \"$PATH\" \"${{__CODEX_SHELL_SNAPSHOT_STATE_0-missing}}\" \"${{__CODEX_SHELL_SNAPSHOT_STATE_1-missing}}\"",
|
|
runtime_path_entry.display(),
|
|
);
|
|
let expected_stdout = format!(
|
|
"{expected_prefix}|profile|missing|missing|{}:{}:/usr/bin:/bin|missing|missing",
|
|
runtime_path_entry.display(),
|
|
profile_path_entry.display(),
|
|
);
|
|
|
|
for attempt in 0..2 {
|
|
let started = context
|
|
.backend
|
|
.start(ExecParams {
|
|
metadata: Default::default(),
|
|
process_id: ProcessId::from(format!("snapshot-{attempt}")),
|
|
argv: vec![shell_path.to_string(), "-lc".to_string(), command.clone()],
|
|
cwd: cwd.clone(),
|
|
env_policy: Some(policy.clone()),
|
|
shell_snapshot: Some(ShellSnapshotRequest {
|
|
scope_id: "attachment-1".to_string(),
|
|
shell: ShellInfo {
|
|
name: if posix_shell { "sh" } else { shell_name }.to_string(),
|
|
path: shell_path.to_string(),
|
|
},
|
|
}),
|
|
env: HashMap::new(),
|
|
tty,
|
|
pipe_stdin: false,
|
|
arg0: (shell_name == "bash-sh").then(|| "sh".to_string()),
|
|
sandbox: (use_sandbox && attempt == 0).then(|| {
|
|
FileSystemSandboxContext::from_permission_profile(
|
|
PermissionProfile::read_only(),
|
|
cwd.clone(),
|
|
)
|
|
}),
|
|
enforce_managed_network: false,
|
|
managed_network: None,
|
|
network_proxy: None,
|
|
})
|
|
.await?;
|
|
let (stdout, stderr, status, closed) =
|
|
collect_process_output_from_events(started.process).await?;
|
|
assert_eq!(
|
|
(stdout, stderr, status, closed),
|
|
(expected_stdout.clone(), String::new(), Some(0), true,)
|
|
);
|
|
}
|
|
|
|
assert_eq!(std::fs::read_to_string(home.path().join("captures"))?, "x");
|
|
assert!(!std::fs::read(home.path().join("tool-captures"))?.is_empty());
|
|
if let Some(server) = context._server {
|
|
assert!(!server.codex_home().join("shell_snapshots").exists());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[serial_test::serial(remote_exec_server)]
|
|
async fn shell_snapshot_v2_remote_managed_proxy_uses_prepared_execution_context() -> Result<()> {
|
|
let context = create_process_context(/*use_remote*/ true).await?;
|
|
let home = TempDir::new()?;
|
|
let cwd = PathUri::from_host_native_path(home.path())?;
|
|
std::fs::write(
|
|
home.path().join(".bashrc"),
|
|
"printf '%s\\n' \"$HTTP_PROXY\" >> \"$HOME/captures\"\ntest \"$CODEX_NETWORK_PROXY_ACTIVE\" = 1 || exit 41\nexport PROFILE_ALLOWED=profile\nprofile_helper() { printf helper; }\n",
|
|
)?;
|
|
let policy = ExecEnvPolicy {
|
|
inherit: ShellEnvironmentPolicyInherit::All,
|
|
ignore_default_excludes: false,
|
|
exclude: Vec::new(),
|
|
r#set: HashMap::from([(
|
|
"HOME".to_string(),
|
|
home.path().to_string_lossy().into_owned(),
|
|
)]),
|
|
include_only: vec![
|
|
"HOME".to_string(),
|
|
"PATH".to_string(),
|
|
"PROFILE_*".to_string(),
|
|
],
|
|
};
|
|
let proxy_config = RemoteNetworkProxyConfig::from_effective_config(&NetworkProxyConfig {
|
|
enabled: true,
|
|
..NetworkProxyConfig::default()
|
|
})?;
|
|
let mut proxy_addresses = Vec::new();
|
|
|
|
for attempt in 0..2 {
|
|
let started = context
|
|
.backend
|
|
.start(ExecParams {
|
|
metadata: Default::default(),
|
|
process_id: ProcessId::from(format!("managed-snapshot-{attempt}")),
|
|
argv: vec![
|
|
"/bin/bash".to_string(),
|
|
"-lc".to_string(),
|
|
"profile_helper; printf '|%s|%s|%s' \"$PROFILE_ALLOWED\" \"$CODEX_NETWORK_PROXY_ACTIVE\" \"$HTTP_PROXY\"".to_string(),
|
|
],
|
|
cwd: cwd.clone(),
|
|
env_policy: Some(policy.clone()),
|
|
shell_snapshot: Some(ShellSnapshotRequest {
|
|
scope_id: "managed-attachment".to_string(),
|
|
shell: ShellInfo {
|
|
name: "bash".to_string(),
|
|
path: "/bin/bash".to_string(),
|
|
},
|
|
}),
|
|
env: HashMap::new(),
|
|
tty: false,
|
|
pipe_stdin: false,
|
|
arg0: None,
|
|
sandbox: None,
|
|
enforce_managed_network: true,
|
|
managed_network: None,
|
|
network_proxy: Some(
|
|
RemoteNetworkProxyLaunchConfig::new(proxy_config.clone()).for_execution(
|
|
"remote-environment".to_string(),
|
|
format!("managed-snapshot-{attempt}"),
|
|
),
|
|
),
|
|
})
|
|
.await?;
|
|
let (stdout, stderr, status, closed) =
|
|
collect_process_output_from_events(started.process).await?;
|
|
let proxy_address = stdout
|
|
.strip_prefix("helper|profile|1|")
|
|
.context("snapshot should restore profile functions and live proxy state")?;
|
|
assert!(proxy_address.starts_with("http://127.0.0.1:"));
|
|
assert_eq!((stderr, status, closed), (String::new(), Some(0), true));
|
|
proxy_addresses.push(proxy_address.to_string());
|
|
}
|
|
|
|
assert_eq!(
|
|
std::fs::read_to_string(home.path().join("captures"))?,
|
|
format!("{}\n", proxy_addresses[0])
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[derive(Clone, Copy)]
|
|
enum CaptureFailure {
|
|
Exit(i32),
|
|
Cancel,
|
|
Timeout,
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test_case(false, false, "bash", 1, CaptureFailure::Exit(7); "local_pipe_recovery")]
|
|
#[test_case(false, true, "bash", 1, CaptureFailure::Exit(7); "local_tty_recovery")]
|
|
#[test_case(true, false, "bash", 1, CaptureFailure::Exit(7); "remote_pipe_recovery")]
|
|
#[test_case(true, true, "bash", 1, CaptureFailure::Exit(7); "remote_tty_recovery")]
|
|
#[test_case(false, false, "bash", 3, CaptureFailure::Exit(7); "local_retry_budget_exhausted")]
|
|
#[test_case(true, false, "bash", 3, CaptureFailure::Exit(7); "remote_retry_budget_exhausted")]
|
|
#[test_case(false, false, "bash", 1, CaptureFailure::Exit(0); "local_invalid_output_recovery")]
|
|
#[cfg_attr(target_os = "macos", test_case(false, false, "zsh", 1, CaptureFailure::Exit(0); "local_zsh_invalid_output_recovery"))]
|
|
#[cfg_attr(target_os = "macos", test_case(false, false, "zsh", 1, CaptureFailure::Exit(7); "local_zsh_recovery"))]
|
|
#[cfg_attr(target_os = "macos", test_case(true, false, "zsh", 1, CaptureFailure::Exit(7); "remote_zsh_recovery"))]
|
|
#[test_case(false, false, "bash", 1, CaptureFailure::Cancel; "cancel_capture")]
|
|
#[test_case(false, false, "bash", 1, CaptureFailure::Timeout; "timeout_capture")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[serial_test::serial(remote_exec_server)]
|
|
async fn shell_snapshot_v2_capture_failure_falls_back_and_retries(
|
|
use_remote: bool,
|
|
tty: bool,
|
|
shell_name: &str,
|
|
failures_before_repair: usize,
|
|
failure: CaptureFailure,
|
|
) -> Result<()> {
|
|
if use_remote
|
|
&& let Some(warning) =
|
|
codex_sandboxing::system_bwrap_warning(&PermissionProfile::workspace_write())
|
|
{
|
|
eprintln!("skipping sandbox test: {warning}");
|
|
return Ok(());
|
|
}
|
|
let context = create_process_context(use_remote).await?;
|
|
let home = TempDir::new()?;
|
|
let cwd = PathUri::from_host_native_path(home.path())?;
|
|
let (shell_path, profile_name) = match shell_name {
|
|
"bash" => ("/bin/bash", ".bashrc"),
|
|
"zsh" => ("/bin/zsh", ".zshrc"),
|
|
name => anyhow::bail!("unsupported test shell {name}"),
|
|
};
|
|
let child_pids = home.path().join("startup-pids");
|
|
let _cleanup = shell_snapshot::StartupProcesses(child_pids.clone());
|
|
// Sandboxed remote PIDs may be namespace-local; check descendants on the native path.
|
|
let startup_child = if use_remote {
|
|
""
|
|
} else {
|
|
"/bin/sleep 30 >/dev/null 2>&1 &\nprintf '%s\\n' \"$!\" >> \"$HOME/startup-pids\"\n"
|
|
};
|
|
let finish = match failure {
|
|
CaptureFailure::Exit(code) => format!("exit {code}"),
|
|
CaptureFailure::Cancel | CaptureFailure::Timeout => "wait".to_string(),
|
|
};
|
|
std::fs::write(
|
|
home.path().join(profile_name),
|
|
format!("printf x >> \"$HOME/captures\"\n{startup_child}{finish}\n"),
|
|
)?;
|
|
let policy = ExecEnvPolicy {
|
|
inherit: ShellEnvironmentPolicyInherit::All,
|
|
ignore_default_excludes: false,
|
|
exclude: Vec::new(),
|
|
r#set: HashMap::from([(
|
|
"HOME".to_string(),
|
|
home.path().to_string_lossy().into_owned(),
|
|
)]),
|
|
include_only: vec!["HOME".to_string(), "PATH".to_string()],
|
|
};
|
|
let mut params = ExecParams {
|
|
metadata: Default::default(),
|
|
process_id: ProcessId::from("snapshot-first"),
|
|
argv: vec![
|
|
shell_path.to_string(),
|
|
"-lc".to_string(),
|
|
"if command -v profile_helper >/dev/null; then profile_helper; else printf original; fi".to_string(),
|
|
],
|
|
cwd: cwd.clone(),
|
|
env_policy: Some(policy),
|
|
shell_snapshot: Some(ShellSnapshotRequest {
|
|
scope_id: "attachment-1".to_string(),
|
|
shell: ShellInfo {
|
|
name: shell_name.to_string(),
|
|
path: shell_path.to_string(),
|
|
},
|
|
}),
|
|
env: HashMap::new(),
|
|
tty,
|
|
pipe_stdin: false,
|
|
arg0: None,
|
|
sandbox: use_remote.then(|| {
|
|
FileSystemSandboxContext::from_permission_profile(
|
|
PermissionProfile::workspace_write(),
|
|
cwd,
|
|
)
|
|
}),
|
|
enforce_managed_network: false,
|
|
managed_network: None,
|
|
network_proxy: None,
|
|
};
|
|
|
|
for attempt in 0..failures_before_repair {
|
|
params.process_id = ProcessId::from(format!("snapshot-fallback-{attempt}"));
|
|
let mut start = context.backend.start(params.clone());
|
|
if matches!(failure, CaptureFailure::Cancel) {
|
|
tokio::select! {
|
|
result = &mut start => anyhow::bail!("capture finished before cancellation: {}", result.is_ok()),
|
|
_ = async {
|
|
while std::fs::read_to_string(&child_pids).unwrap_or_default().trim().is_empty() {
|
|
sleep(Duration::from_millis(20)).await;
|
|
}
|
|
} => {}
|
|
}
|
|
drop(start);
|
|
} else {
|
|
let fallback = start.await?;
|
|
assert_eq!(
|
|
collect_process_output_from_events(fallback.process).await?,
|
|
("original".to_string(), String::new(), Some(0), true)
|
|
);
|
|
}
|
|
if !use_remote {
|
|
let pids = std::fs::read_to_string(&child_pids)?;
|
|
let pid = pids
|
|
.split_whitespace()
|
|
.last()
|
|
.context("startup child PID")?;
|
|
timeout(Duration::from_secs(5), async {
|
|
loop {
|
|
let output = std::process::Command::new("/bin/ps")
|
|
.args(["-o", "stat=", "-p", pid])
|
|
.output()?;
|
|
let state = String::from_utf8(output.stdout)?;
|
|
if state.trim().is_empty() || state.trim().starts_with('Z') {
|
|
// Forget the completed child before retries can reuse its PID.
|
|
std::fs::remove_file(&child_pids)?;
|
|
return Ok::<_, anyhow::Error>(());
|
|
}
|
|
sleep(Duration::from_millis(20)).await;
|
|
}
|
|
})
|
|
.await
|
|
.context("failed capture left its startup child running")??;
|
|
}
|
|
// A real remote executor has its own clock; the unit test uses a
|
|
// paused clock to check requests made during the one-second backoff.
|
|
sleep(Duration::from_millis(1100)).await;
|
|
}
|
|
assert_eq!(
|
|
std::fs::read_to_string(home.path().join("captures"))?,
|
|
"x".repeat(failures_before_repair)
|
|
);
|
|
|
|
std::fs::write(
|
|
home.path().join(profile_name),
|
|
"printf x >> \"$HOME/captures\"\nprofile_helper() { printf recovered; }\n",
|
|
)?;
|
|
let (expected_output, expected_captures) = if failures_before_repair == 3 {
|
|
("original", "xxx")
|
|
} else {
|
|
("recovered", "xx")
|
|
};
|
|
for attempt in 0..2 {
|
|
params.process_id = ProcessId::from(format!("snapshot-after-repair-{attempt}"));
|
|
let started = context.backend.start(params.clone()).await?;
|
|
assert_eq!(
|
|
collect_process_output_from_events(started.process).await?,
|
|
(expected_output.to_string(), String::new(), Some(0), true)
|
|
);
|
|
}
|
|
assert_eq!(
|
|
std::fs::read_to_string(home.path().join("captures"))?,
|
|
expected_captures
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn remote_sandboxed_process_preserves_custom_arg0() -> Result<()> {
|
|
if let Some(warning) = codex_sandboxing::system_bwrap_warning(&PermissionProfile::read_only()) {
|
|
eprintln!("skipping bwrap test: {warning}");
|
|
return Ok(());
|
|
}
|
|
|
|
let context = create_process_context(/*use_remote*/ true).await?;
|
|
let workspace = TempDir::new()?;
|
|
let outside_workspace = TempDir::new()?;
|
|
let denied_file = outside_workspace.path().join("denied.txt");
|
|
std::fs::write(&denied_file, b"denied")?;
|
|
let cwd = PathUri::from_host_native_path(workspace.path())?;
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Minimal,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
missing_path_behavior: None,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
missing_path_behavior: None,
|
|
},
|
|
]);
|
|
let sandbox = FileSystemSandboxContext::from_permission_profile(
|
|
PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted),
|
|
cwd.clone(),
|
|
);
|
|
let session = context
|
|
.backend
|
|
.start(ExecParams {
|
|
metadata: Default::default(),
|
|
process_id: ProcessId::from("proc-custom-arg0"),
|
|
argv: vec![
|
|
"/bin/sh".to_string(),
|
|
"-c".to_string(),
|
|
"printf '%s' \"$0\"; if /bin/cat \"$CODEX_TEST_DENIED_FILE\" >/dev/null 2>&1; then exit 42; fi"
|
|
.to_string(),
|
|
],
|
|
cwd,
|
|
shell_snapshot: None,
|
|
env_policy: None,
|
|
env: HashMap::from([
|
|
("PATH".to_string(), std::env::var("PATH")?),
|
|
(
|
|
"CODEX_TEST_DENIED_FILE".to_string(),
|
|
denied_file.to_string_lossy().into_owned(),
|
|
),
|
|
]),
|
|
tty: false,
|
|
pipe_stdin: false,
|
|
arg0: Some("custom-arg0".to_string()),
|
|
sandbox: Some(sandbox),
|
|
enforce_managed_network: false,
|
|
managed_network: None,
|
|
network_proxy: None,
|
|
})
|
|
.await?;
|
|
let output = collect_process_output_from_events(session.process).await?;
|
|
|
|
assert_eq!(
|
|
output,
|
|
("custom-arg0".to_string(), String::new(), Some(0), true)
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> {
|
|
let context = create_process_context(use_remote).await?;
|
|
let session = context
|
|
.backend
|
|
.start(ExecParams {
|
|
metadata: Default::default(),
|
|
process_id: ProcessId::from("proc-1"),
|
|
argv: vec!["true".to_string()],
|
|
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
|
shell_snapshot: None,
|
|
env_policy: /*env_policy*/ None,
|
|
env: Default::default(),
|
|
tty: false,
|
|
pipe_stdin: false,
|
|
arg0: None,
|
|
sandbox: None,
|
|
enforce_managed_network: false,
|
|
managed_network: None,
|
|
network_proxy: None,
|
|
})
|
|
.await?;
|
|
assert_eq!(session.process.process_id().as_str(), "proc-1");
|
|
let wake_rx = session.process.subscribe_wake();
|
|
let (_, exit_code, closed) =
|
|
collect_process_output_from_reads(session.process, wake_rx).await?;
|
|
|
|
assert_eq!(exit_code, Some(0));
|
|
assert!(closed);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn remote_process_keeps_sandbox_helper_visible_with_restricted_reads() -> Result<()> {
|
|
if let Some(warning) = codex_sandboxing::system_bwrap_warning(&PermissionProfile::read_only()) {
|
|
eprintln!("skipping bwrap test: {warning}");
|
|
return Ok(());
|
|
}
|
|
|
|
let context = create_process_context(/*use_remote*/ true).await?;
|
|
let workspace = TempDir::new()?;
|
|
let file = workspace.path().join("allowed.txt");
|
|
std::fs::write(&file, b"allowed")?;
|
|
let cwd = PathUri::from_host_native_path(workspace.path())?;
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Minimal,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
missing_path_behavior: None,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
missing_path_behavior: None,
|
|
},
|
|
]);
|
|
let sandbox = FileSystemSandboxContext::from_permission_profile(
|
|
PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted),
|
|
cwd.clone(),
|
|
);
|
|
|
|
let session = context
|
|
.backend
|
|
.start(ExecParams {
|
|
metadata: Default::default(),
|
|
process_id: ProcessId::from("proc-restricted-helper"),
|
|
argv: vec!["/bin/cat".to_string(), file.to_string_lossy().into_owned()],
|
|
cwd,
|
|
shell_snapshot: None,
|
|
env_policy: /*env_policy*/ None,
|
|
env: HashMap::from([("PATH".to_string(), std::env::var("PATH")?)]),
|
|
tty: false,
|
|
pipe_stdin: false,
|
|
arg0: None,
|
|
sandbox: Some(sandbox),
|
|
enforce_managed_network: false,
|
|
managed_network: None,
|
|
network_proxy: None,
|
|
})
|
|
.await?;
|
|
let output = collect_process_output_from_events(session.process).await?;
|
|
|
|
assert_eq!(
|
|
output,
|
|
("allowed".to_string(), String::new(), Some(0), true)
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn remote_tty_process_uses_configured_sandbox_helper_with_hostile_path() -> Result<()> {
|
|
if let Some(warning) = codex_sandboxing::system_bwrap_warning(&PermissionProfile::read_only()) {
|
|
eprintln!("skipping bwrap test: {warning}");
|
|
return Ok(());
|
|
}
|
|
|
|
let context = create_process_context(/*use_remote*/ true).await?;
|
|
let workspace = TempDir::new()?;
|
|
let file = workspace.path().join("allowed.txt");
|
|
std::fs::write(&file, b"allowed")?;
|
|
let hostile_helper = workspace.path().join("codex-linux-sandbox");
|
|
std::fs::write(&hostile_helper, b"#!/bin/sh\nprintf hostile")?;
|
|
let mut permissions = std::fs::metadata(&hostile_helper)?.permissions();
|
|
permissions.set_mode(0o755);
|
|
std::fs::set_permissions(&hostile_helper, permissions)?;
|
|
let path = std::env::var_os("PATH").context("PATH is not set")?;
|
|
let hostile_path = std::env::join_paths(
|
|
std::iter::once(workspace.path().to_path_buf()).chain(std::env::split_paths(&path)),
|
|
)?;
|
|
let cwd = PathUri::from_host_native_path(workspace.path())?;
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Minimal,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
missing_path_behavior: None,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
missing_path_behavior: None,
|
|
},
|
|
]);
|
|
let sandbox = FileSystemSandboxContext::from_permission_profile(
|
|
PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted),
|
|
cwd.clone(),
|
|
);
|
|
|
|
let session = context
|
|
.backend
|
|
.start(ExecParams {
|
|
metadata: Default::default(),
|
|
process_id: ProcessId::from("proc-hostile-helper-path"),
|
|
argv: vec!["/bin/cat".to_string(), file.to_string_lossy().into_owned()],
|
|
cwd,
|
|
shell_snapshot: None,
|
|
env_policy: /*env_policy*/ None,
|
|
env: HashMap::from([(
|
|
"PATH".to_string(),
|
|
hostile_path.to_string_lossy().into_owned(),
|
|
)]),
|
|
tty: true,
|
|
pipe_stdin: false,
|
|
arg0: None,
|
|
sandbox: Some(sandbox),
|
|
enforce_managed_network: false,
|
|
managed_network: None,
|
|
network_proxy: None,
|
|
})
|
|
.await?;
|
|
let output = collect_process_output_from_events(session.process).await?;
|
|
|
|
assert_eq!(
|
|
output,
|
|
("allowed".to_string(), String::new(), Some(0), true)
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn remote_process_preserves_empty_workspace_roots() -> Result<()> {
|
|
if let Some(warning) = codex_sandboxing::system_bwrap_warning(&PermissionProfile::read_only()) {
|
|
eprintln!("skipping bwrap test: {warning}");
|
|
return Ok(());
|
|
}
|
|
|
|
let context = create_process_context(/*use_remote*/ true).await?;
|
|
let tmp = TempDir::new()?;
|
|
let file = tmp.path().join("excluded.txt");
|
|
std::fs::write(&file, b"excluded")?;
|
|
let cwd = PathUri::from_host_native_path(tmp.path())?;
|
|
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
missing_path_behavior: None,
|
|
}]);
|
|
let mut sandbox = FileSystemSandboxContext::from_permission_profile(
|
|
PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted),
|
|
cwd.clone(),
|
|
);
|
|
sandbox.workspace_roots.clear();
|
|
|
|
let session = context
|
|
.backend
|
|
.start(ExecParams {
|
|
metadata: Default::default(),
|
|
process_id: ProcessId::from("proc-empty-workspace-roots"),
|
|
argv: vec!["/bin/cat".to_string(), file.to_string_lossy().into_owned()],
|
|
cwd,
|
|
shell_snapshot: None,
|
|
env_policy: None,
|
|
env: HashMap::new(),
|
|
tty: false,
|
|
pipe_stdin: false,
|
|
arg0: None,
|
|
sandbox: Some(sandbox),
|
|
enforce_managed_network: false,
|
|
managed_network: None,
|
|
network_proxy: None,
|
|
})
|
|
.await?;
|
|
let (stdout, _stderr, exit_code, closed) =
|
|
collect_process_output_from_events(session.process).await?;
|
|
|
|
assert!(!stdout.contains("excluded"), "unexpected stdout: {stdout}");
|
|
assert_ne!(exit_code, Some(0));
|
|
assert!(closed);
|
|
Ok(())
|
|
}
|
|
|
|
async fn read_process_until_change(
|
|
session: Arc<dyn ExecProcess>,
|
|
wake_rx: &mut watch::Receiver<u64>,
|
|
after_seq: Option<u64>,
|
|
) -> Result<ReadResponse> {
|
|
let response = session
|
|
.read(after_seq, /*max_bytes*/ None, /*wait_ms*/ Some(0))
|
|
.await?;
|
|
if !response.chunks.is_empty() || response.closed || response.failure.is_some() {
|
|
return Ok(response);
|
|
}
|
|
|
|
timeout(Duration::from_secs(2), wake_rx.changed()).await??;
|
|
session
|
|
.read(after_seq, /*max_bytes*/ None, /*wait_ms*/ Some(0))
|
|
.await
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
async fn collect_process_output_from_reads(
|
|
session: Arc<dyn ExecProcess>,
|
|
mut wake_rx: watch::Receiver<u64>,
|
|
) -> Result<(String, Option<i32>, bool)> {
|
|
let mut output = String::new();
|
|
let mut exit_code = None;
|
|
let mut after_seq = None;
|
|
loop {
|
|
let response =
|
|
read_process_until_change(Arc::clone(&session), &mut wake_rx, after_seq).await?;
|
|
if let Some(message) = response.failure {
|
|
anyhow::bail!("process failed before closed state: {message}");
|
|
}
|
|
for chunk in response.chunks {
|
|
output.push_str(&String::from_utf8_lossy(&chunk.chunk.into_inner()));
|
|
after_seq = Some(chunk.seq);
|
|
}
|
|
if response.exited {
|
|
exit_code = response.exit_code;
|
|
}
|
|
if response.closed {
|
|
break;
|
|
}
|
|
after_seq = response.next_seq.checked_sub(1).or(after_seq);
|
|
}
|
|
drop(session);
|
|
Ok((output, exit_code, true))
|
|
}
|
|
|
|
async fn collect_process_output_from_events(
|
|
session: Arc<dyn ExecProcess>,
|
|
) -> Result<(String, String, Option<i32>, bool)> {
|
|
collect_process_output_from_events_with_timeout(session, Duration::from_secs(2)).await
|
|
}
|
|
|
|
async fn collect_process_output_from_events_with_timeout(
|
|
session: Arc<dyn ExecProcess>,
|
|
event_timeout: Duration,
|
|
) -> Result<(String, String, Option<i32>, bool)> {
|
|
let mut events = session.subscribe_events();
|
|
let mut stdout = String::new();
|
|
let mut stderr = String::new();
|
|
let mut exit_code = None;
|
|
loop {
|
|
match timeout(event_timeout, events.recv()).await?? {
|
|
ExecProcessEvent::Output(chunk) => match chunk.stream {
|
|
ExecOutputStream::Stdout | ExecOutputStream::Pty => {
|
|
stdout.push_str(&String::from_utf8_lossy(&chunk.chunk.into_inner()));
|
|
}
|
|
ExecOutputStream::Stderr => {
|
|
stderr.push_str(&String::from_utf8_lossy(&chunk.chunk.into_inner()));
|
|
}
|
|
},
|
|
ExecProcessEvent::Exited {
|
|
seq: _,
|
|
exit_code: code,
|
|
..
|
|
} => {
|
|
exit_code = Some(code);
|
|
}
|
|
ExecProcessEvent::Closed { seq: _ } => {
|
|
drop(session);
|
|
return Ok((stdout, stderr, exit_code, true));
|
|
}
|
|
ExecProcessEvent::Failed(message) => {
|
|
anyhow::bail!("process failed before closed state: {message}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn collect_process_event_snapshots(
|
|
session: Arc<dyn ExecProcess>,
|
|
) -> Result<Vec<ProcessEventSnapshot>> {
|
|
let mut events = session.subscribe_events();
|
|
let mut snapshots = Vec::new();
|
|
loop {
|
|
let snapshot = match timeout(Duration::from_secs(2), events.recv()).await?? {
|
|
ExecProcessEvent::Output(chunk) => ProcessEventSnapshot::Output {
|
|
seq: chunk.seq,
|
|
stream: chunk.stream,
|
|
text: String::from_utf8_lossy(&chunk.chunk.into_inner()).into_owned(),
|
|
},
|
|
ExecProcessEvent::Exited { seq, exit_code, .. } => {
|
|
ProcessEventSnapshot::Exited { seq, exit_code }
|
|
}
|
|
ExecProcessEvent::Closed { seq } => ProcessEventSnapshot::Closed { seq },
|
|
ExecProcessEvent::Failed(message) => {
|
|
anyhow::bail!("process failed before closed state: {message}");
|
|
}
|
|
};
|
|
let closed = matches!(snapshot, ProcessEventSnapshot::Closed { .. });
|
|
snapshots.push(snapshot);
|
|
if closed {
|
|
drop(session);
|
|
return Ok(snapshots);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> {
|
|
let context = create_process_context(use_remote).await?;
|
|
let process_id = "proc-stream".to_string();
|
|
let session = context
|
|
.backend
|
|
.start(ExecParams {
|
|
metadata: Default::default(),
|
|
process_id: process_id.clone().into(),
|
|
argv: vec![
|
|
"/bin/sh".to_string(),
|
|
"-c".to_string(),
|
|
"sleep 0.05; printf 'session output\\n'".to_string(),
|
|
],
|
|
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
|
shell_snapshot: None,
|
|
env_policy: /*env_policy*/ None,
|
|
env: Default::default(),
|
|
tty: false,
|
|
pipe_stdin: false,
|
|
arg0: None,
|
|
sandbox: None,
|
|
enforce_managed_network: false,
|
|
managed_network: None,
|
|
network_proxy: None,
|
|
})
|
|
.await?;
|
|
assert_eq!(session.process.process_id().as_str(), process_id);
|
|
|
|
let StartedExecProcess { process, .. } = session;
|
|
let wake_rx = process.subscribe_wake();
|
|
let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?;
|
|
assert_eq!(output, "session output\n");
|
|
assert_eq!(exit_code, Some(0));
|
|
assert!(closed);
|
|
Ok(())
|
|
}
|
|
|
|
async fn assert_exec_process_pushes_events(use_remote: bool) -> Result<()> {
|
|
let context = create_process_context(use_remote).await?;
|
|
let process_id = "proc-events".to_string();
|
|
let session = context
|
|
.backend
|
|
.start(ExecParams {
|
|
metadata: Default::default(),
|
|
process_id: process_id.clone().into(),
|
|
argv: vec![
|
|
"/bin/sh".to_string(),
|
|
"-c".to_string(),
|
|
"printf 'event output\\n'; sleep 0.1; printf 'event err\\n' >&2; sleep 0.1; exit 7".to_string(),
|
|
],
|
|
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
|
shell_snapshot: None,
|
|
env_policy: /*env_policy*/ None,
|
|
env: Default::default(),
|
|
tty: false,
|
|
pipe_stdin: false,
|
|
arg0: None,
|
|
sandbox: None,
|
|
enforce_managed_network: false,
|
|
managed_network: None,
|
|
network_proxy: None,
|
|
})
|
|
.await?;
|
|
assert_eq!(session.process.process_id().as_str(), process_id);
|
|
|
|
let StartedExecProcess { process, .. } = session;
|
|
let actual = collect_process_event_snapshots(process).await?;
|
|
assert_eq!(
|
|
actual,
|
|
vec![
|
|
ProcessEventSnapshot::Output {
|
|
seq: 1,
|
|
stream: ExecOutputStream::Stdout,
|
|
text: "event output\n".to_string(),
|
|
},
|
|
ProcessEventSnapshot::Output {
|
|
seq: 2,
|
|
stream: ExecOutputStream::Stderr,
|
|
text: "event err\n".to_string(),
|
|
},
|
|
ProcessEventSnapshot::Exited {
|
|
seq: 3,
|
|
exit_code: 7,
|
|
},
|
|
ProcessEventSnapshot::Closed { seq: 4 },
|
|
]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn assert_exec_process_replays_events_after_close(use_remote: bool) -> Result<()> {
|
|
let context = create_process_context(use_remote).await?;
|
|
let process_id = "proc-events-late".to_string();
|
|
let session = context
|
|
.backend
|
|
.start(ExecParams {
|
|
metadata: Default::default(),
|
|
process_id: process_id.clone().into(),
|
|
argv: vec![
|
|
"/bin/sh".to_string(),
|
|
"-c".to_string(),
|
|
"printf 'late one\\n'; printf 'late two\\n'".to_string(),
|
|
],
|
|
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
|
shell_snapshot: None,
|
|
env_policy: /*env_policy*/ None,
|
|
env: Default::default(),
|
|
tty: false,
|
|
pipe_stdin: false,
|
|
arg0: None,
|
|
sandbox: None,
|
|
enforce_managed_network: false,
|
|
managed_network: None,
|
|
network_proxy: None,
|
|
})
|
|
.await?;
|
|
assert_eq!(session.process.process_id().as_str(), process_id);
|
|
|
|
let StartedExecProcess { process, .. } = session;
|
|
let wake_rx = process.subscribe_wake();
|
|
let read_result = collect_process_output_from_reads(Arc::clone(&process), wake_rx).await?;
|
|
assert_eq!(
|
|
read_result,
|
|
("late one\nlate two\n".to_string(), Some(0), true)
|
|
);
|
|
|
|
let event_result = collect_process_output_from_events(process).await?;
|
|
assert_eq!(
|
|
event_result,
|
|
(
|
|
"late one\nlate two\n".to_string(),
|
|
String::new(),
|
|
Some(0),
|
|
true
|
|
)
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn assert_exec_process_retains_output_after_exit_until_streams_close(
|
|
use_remote: bool,
|
|
) -> Result<()> {
|
|
let context = create_process_context(use_remote).await?;
|
|
let (helper_binary, _) = current_test_binary_helper_paths()?;
|
|
let release_dir = TempDir::new()?;
|
|
let release_path = release_dir.path().join("release-delayed-output");
|
|
let process_id = "proc-output-after-exit".to_string();
|
|
let session = context
|
|
.backend
|
|
.start(ExecParams {
|
|
metadata: Default::default(),
|
|
process_id: process_id.clone().into(),
|
|
argv: vec![
|
|
helper_binary.to_string_lossy().into_owned(),
|
|
DELAYED_OUTPUT_AFTER_EXIT_PARENT_ARG.to_string(),
|
|
release_path.to_string_lossy().into_owned(),
|
|
],
|
|
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
|
shell_snapshot: None,
|
|
env_policy: /*env_policy*/ None,
|
|
env: Default::default(),
|
|
tty: false,
|
|
pipe_stdin: false,
|
|
arg0: None,
|
|
sandbox: None,
|
|
enforce_managed_network: false,
|
|
managed_network: None,
|
|
network_proxy: None,
|
|
})
|
|
.await?;
|
|
assert_eq!(session.process.process_id().as_str(), process_id);
|
|
|
|
let StartedExecProcess { process, .. } = session;
|
|
|
|
let exit_response = timeout(
|
|
Duration::from_secs(2),
|
|
process.read(
|
|
/*after_seq*/ None,
|
|
/*max_bytes*/ None,
|
|
/*wait_ms*/ Some(2_000),
|
|
),
|
|
)
|
|
.await??;
|
|
assert!(
|
|
exit_response.chunks.is_empty(),
|
|
"parent should exit before child writes delayed output"
|
|
);
|
|
assert_eq!(exit_response.exit_code, Some(0));
|
|
assert!(!exit_response.closed);
|
|
let exit_seq = exit_response
|
|
.next_seq
|
|
.checked_sub(1)
|
|
.context("exit response should advance next_seq")?;
|
|
std::fs::write(&release_path, b"go")?;
|
|
|
|
let late_response = timeout(
|
|
Duration::from_secs(2),
|
|
process.read(
|
|
/*after_seq*/ Some(exit_seq),
|
|
/*max_bytes*/ None,
|
|
/*wait_ms*/ Some(2_000),
|
|
),
|
|
)
|
|
.await??;
|
|
let mut late_output = String::new();
|
|
for chunk in late_response.chunks {
|
|
assert_eq!(chunk.stream, ExecOutputStream::Stdout);
|
|
late_output.push_str(&String::from_utf8_lossy(&chunk.chunk.into_inner()));
|
|
}
|
|
assert_eq!(late_output, "late output after exit\n");
|
|
|
|
let wake_rx = process.subscribe_wake();
|
|
let actual = collect_process_output_from_reads(process, wake_rx).await?;
|
|
assert_eq!(
|
|
actual,
|
|
("late output after exit\n".to_string(), Some(0), true)
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> {
|
|
let context = create_process_context(use_remote).await?;
|
|
let process_id = "proc-stdin".to_string();
|
|
let session = context
|
|
.backend
|
|
.start(ExecParams {
|
|
metadata: Default::default(),
|
|
process_id: process_id.clone().into(),
|
|
argv: vec![
|
|
// Use `/bin/sh` instead of Python so this stdin round-trip test
|
|
// stays portable across Bazel and non-macOS runners where
|
|
// `/usr/bin/python3` is not guaranteed to exist.
|
|
"/bin/sh".to_string(),
|
|
"-c".to_string(),
|
|
"IFS= read line; printf 'from-stdin:%s\\n' \"$line\"".to_string(),
|
|
],
|
|
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
|
shell_snapshot: None,
|
|
env_policy: /*env_policy*/ None,
|
|
env: Default::default(),
|
|
tty: true,
|
|
pipe_stdin: false,
|
|
arg0: None,
|
|
sandbox: None,
|
|
enforce_managed_network: false,
|
|
managed_network: None,
|
|
network_proxy: None,
|
|
})
|
|
.await?;
|
|
assert_eq!(session.process.process_id().as_str(), process_id);
|
|
|
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
session.process.write(b"hello\n".to_vec()).await?;
|
|
let StartedExecProcess { process, .. } = session;
|
|
let wake_rx = process.subscribe_wake();
|
|
let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?;
|
|
|
|
assert!(
|
|
output.contains("from-stdin:hello"),
|
|
"unexpected output: {output:?}"
|
|
);
|
|
assert_eq!(exit_code, Some(0));
|
|
assert!(closed);
|
|
Ok(())
|
|
}
|
|
|
|
async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Result<()> {
|
|
let context = create_process_context(use_remote).await?;
|
|
let process_id = "proc-stdin-pipe".to_string();
|
|
let session = context
|
|
.backend
|
|
.start(ExecParams {
|
|
metadata: Default::default(),
|
|
process_id: process_id.clone().into(),
|
|
argv: vec![
|
|
"/bin/sh".to_string(),
|
|
"-c".to_string(),
|
|
"IFS= read line; printf 'from-stdin:%s\\n' \"$line\"".to_string(),
|
|
],
|
|
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
|
shell_snapshot: None,
|
|
env_policy: /*env_policy*/ None,
|
|
env: Default::default(),
|
|
tty: false,
|
|
pipe_stdin: true,
|
|
arg0: None,
|
|
sandbox: None,
|
|
enforce_managed_network: false,
|
|
managed_network: None,
|
|
network_proxy: None,
|
|
})
|
|
.await?;
|
|
assert_eq!(session.process.process_id().as_str(), process_id);
|
|
|
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
let write_response = session.process.write(b"hello\n".to_vec()).await?;
|
|
assert_eq!(write_response.status, WriteStatus::Accepted);
|
|
let StartedExecProcess { process, .. } = session;
|
|
let wake_rx = process.subscribe_wake();
|
|
let actual = collect_process_output_from_reads(process, wake_rx).await?;
|
|
|
|
assert_eq!(actual, ("from-stdin:hello\n".to_string(), Some(0), true));
|
|
Ok(())
|
|
}
|
|
|
|
async fn assert_remote_windows_sandbox_process_write(
|
|
expected_sandbox_type: codex_sandboxing::SandboxType,
|
|
tty: bool,
|
|
) -> Result<()> {
|
|
if expected_sandbox_type == codex_sandboxing::SandboxType::WindowsMxc {
|
|
crate::skip_if_mxc_unavailable!(Ok(()));
|
|
}
|
|
let context = create_process_context(/*use_remote*/ true).await?;
|
|
let workspace = TempDir::new()?;
|
|
let blocked_file = workspace.path().join("blocked.txt");
|
|
let cwd = PathUri::from_host_native_path(workspace.path())?;
|
|
let mut sandbox = FileSystemSandboxContext::from_legacy_sandbox_policy(
|
|
SandboxPolicy::new_read_only_policy(),
|
|
cwd.clone(),
|
|
)?;
|
|
match expected_sandbox_type {
|
|
codex_sandboxing::SandboxType::WindowsRestrictedToken => {
|
|
sandbox.windows_sandbox_selection = WindowsSandboxSelection::RestrictedToken;
|
|
}
|
|
codex_sandboxing::SandboxType::WindowsMxc => {
|
|
sandbox.windows_sandbox_selection = WindowsSandboxSelection::Mxc;
|
|
}
|
|
codex_sandboxing::SandboxType::None
|
|
| codex_sandboxing::SandboxType::MacosSeatbelt
|
|
| codex_sandboxing::SandboxType::LinuxSeccomp => {
|
|
anyhow::bail!("expected a Windows sandbox type")
|
|
}
|
|
}
|
|
|
|
let session = match context
|
|
.backend
|
|
.start(ExecParams {
|
|
metadata: Default::default(),
|
|
process_id: ProcessId::from("proc-windows-sandbox-stdin"),
|
|
argv: vec![
|
|
r"C:\Windows\System32\cmd.exe".to_string(),
|
|
"/D".to_string(),
|
|
"/V:ON".to_string(),
|
|
"/S".to_string(),
|
|
"/C".to_string(),
|
|
format!(
|
|
"set /P line= & echo blocked > \"{}\" & echo from-stdin:!line!",
|
|
blocked_file.display()
|
|
),
|
|
],
|
|
cwd,
|
|
shell_snapshot: None,
|
|
env_policy: /*env_policy*/ None,
|
|
env: Default::default(),
|
|
tty,
|
|
pipe_stdin: !tty,
|
|
arg0: None,
|
|
sandbox: Some(sandbox),
|
|
enforce_managed_network: false,
|
|
managed_network: None,
|
|
network_proxy: None,
|
|
})
|
|
.await
|
|
{
|
|
Ok(session) => session,
|
|
Err(err) => return Err(err.into()),
|
|
};
|
|
assert_eq!(session.sandbox_type, Some(expected_sandbox_type));
|
|
|
|
let input = if tty { b"hello\r" } else { b"hello\n" };
|
|
let write_response = session.process.write(input.to_vec()).await?;
|
|
assert_eq!(write_response.status, WriteStatus::Accepted);
|
|
let StartedExecProcess { process, .. } = session;
|
|
let wake_rx = process.subscribe_wake();
|
|
let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?;
|
|
|
|
assert!(
|
|
output.contains("from-stdin:hello"),
|
|
"unexpected output: {output:?}"
|
|
);
|
|
assert_eq!(exit_code, Some(0));
|
|
assert!(closed);
|
|
assert!(!blocked_file.exists());
|
|
Ok(())
|
|
}
|
|
|
|
async fn assert_exec_process_rejects_write_without_pipe_stdin(use_remote: bool) -> Result<()> {
|
|
let context = create_process_context(use_remote).await?;
|
|
let process_id = "proc-stdin-closed".to_string();
|
|
let session = context
|
|
.backend
|
|
.start(ExecParams {
|
|
metadata: Default::default(),
|
|
process_id: process_id.clone().into(),
|
|
argv: vec![
|
|
"/bin/sh".to_string(),
|
|
"-c".to_string(),
|
|
"sleep 0.3; if IFS= read -r line; then printf 'read:%s\\n' \"$line\"; else printf 'eof\\n'; fi".to_string(),
|
|
],
|
|
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
|
shell_snapshot: None,
|
|
env_policy: /*env_policy*/ None,
|
|
env: Default::default(),
|
|
tty: false,
|
|
pipe_stdin: false,
|
|
arg0: None,
|
|
sandbox: None,
|
|
enforce_managed_network: false,
|
|
managed_network: None,
|
|
network_proxy: None,
|
|
})
|
|
.await?;
|
|
assert_eq!(session.process.process_id().as_str(), process_id);
|
|
|
|
let write_response = session.process.write(b"ignored\n".to_vec()).await?;
|
|
assert_eq!(write_response.status, WriteStatus::StdinClosed);
|
|
let StartedExecProcess { process, .. } = session;
|
|
let wake_rx = process.subscribe_wake();
|
|
let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?;
|
|
|
|
assert_eq!(output, "eof\n");
|
|
assert_eq!(exit_code, Some(0));
|
|
assert!(closed);
|
|
Ok(())
|
|
}
|
|
|
|
async fn assert_exec_process_signal_interrupts_process(use_remote: bool) -> Result<()> {
|
|
let context = create_process_context(use_remote).await?;
|
|
let process_id = "proc-signal".to_string();
|
|
let session = context
|
|
.backend
|
|
.start(ExecParams {
|
|
metadata: Default::default(),
|
|
process_id: process_id.clone().into(),
|
|
argv: vec![
|
|
"/bin/sh".to_string(),
|
|
"-c".to_string(),
|
|
"trap 'printf \"signal:2\\n\"; exit 7' INT; printf 'ready\\n'; while :; do :; done".to_string(),
|
|
],
|
|
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
|
shell_snapshot: None,
|
|
env_policy: /*env_policy*/ None,
|
|
env: Default::default(),
|
|
tty: false,
|
|
pipe_stdin: false,
|
|
arg0: None,
|
|
sandbox: None,
|
|
enforce_managed_network: false,
|
|
managed_network: None,
|
|
network_proxy: None,
|
|
})
|
|
.await?;
|
|
assert_eq!(session.process.process_id().as_str(), process_id);
|
|
|
|
let StartedExecProcess { process, .. } = session;
|
|
let mut wake_rx = process.subscribe_wake();
|
|
let mut ready_output = String::new();
|
|
let mut after_seq = None;
|
|
loop {
|
|
let response =
|
|
read_process_until_change(Arc::clone(&process), &mut wake_rx, after_seq).await?;
|
|
for chunk in response.chunks {
|
|
ready_output.push_str(&String::from_utf8_lossy(&chunk.chunk.into_inner()));
|
|
after_seq = Some(chunk.seq);
|
|
}
|
|
if ready_output.contains("ready\n") {
|
|
break;
|
|
}
|
|
if response.closed {
|
|
anyhow::bail!("process closed before readiness marker: {ready_output:?}");
|
|
}
|
|
after_seq = response.next_seq.checked_sub(1).or(after_seq);
|
|
}
|
|
|
|
process.signal(ProcessSignal::Interrupt).await?;
|
|
let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?;
|
|
|
|
assert!(
|
|
output.contains("signal:2"),
|
|
"expected signal handler output, got {output:?}"
|
|
);
|
|
assert_eq!(exit_code, Some(7));
|
|
assert!(closed);
|
|
Ok(())
|
|
}
|
|
|
|
async fn assert_exec_process_signal_terminates_on_windows(use_remote: bool) -> Result<()> {
|
|
let context = create_process_context(use_remote).await?;
|
|
let session = context
|
|
.backend
|
|
.start(ExecParams {
|
|
metadata: Default::default(),
|
|
process_id: ProcessId::from("proc-windows-signal"),
|
|
argv: vec![
|
|
"cmd".to_string(),
|
|
"/C".to_string(),
|
|
"echo ready && ping -n 30 127.0.0.1 >NUL".to_string(),
|
|
],
|
|
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
|
shell_snapshot: None,
|
|
env_policy: /*env_policy*/ None,
|
|
env: Default::default(),
|
|
tty: false,
|
|
pipe_stdin: false,
|
|
arg0: None,
|
|
sandbox: None,
|
|
enforce_managed_network: false,
|
|
managed_network: None,
|
|
network_proxy: None,
|
|
})
|
|
.await?;
|
|
|
|
let StartedExecProcess { process, .. } = session;
|
|
let wake_rx = process.subscribe_wake();
|
|
process.signal(ProcessSignal::Interrupt).await?;
|
|
let (_output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?;
|
|
|
|
assert_eq!(exit_code, Some(1));
|
|
assert!(closed);
|
|
Ok(())
|
|
}
|
|
|
|
async fn assert_exec_process_preserves_queued_events_before_subscribe(
|
|
use_remote: bool,
|
|
) -> Result<()> {
|
|
let context = create_process_context(use_remote).await?;
|
|
let session = context
|
|
.backend
|
|
.start(ExecParams {
|
|
metadata: Default::default(),
|
|
process_id: ProcessId::from("proc-queued"),
|
|
argv: vec![
|
|
"/bin/sh".to_string(),
|
|
"-c".to_string(),
|
|
"printf 'queued output\\n'".to_string(),
|
|
],
|
|
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
|
shell_snapshot: None,
|
|
env_policy: /*env_policy*/ None,
|
|
env: Default::default(),
|
|
tty: false,
|
|
pipe_stdin: false,
|
|
arg0: None,
|
|
sandbox: None,
|
|
enforce_managed_network: false,
|
|
managed_network: None,
|
|
network_proxy: None,
|
|
})
|
|
.await?;
|
|
|
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
|
|
let StartedExecProcess { process, .. } = session;
|
|
let wake_rx = process.subscribe_wake();
|
|
let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?;
|
|
assert_eq!(output, "queued output\n");
|
|
assert_eq!(exit_code, Some(0));
|
|
assert!(closed);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[cfg_attr(not(unix), ignore = "Unix-only exec-server process test")]
|
|
// Serialize tests that launch a real exec-server process through the full CLI.
|
|
#[serial_test::serial(remote_exec_server)]
|
|
async fn remote_exec_process_recovers_after_transport_disconnect() -> Result<()> {
|
|
let server = exec_server().await?;
|
|
let mut proxy = server.disconnectable_websocket_proxy().await?;
|
|
let environment = Environment::create_for_tests(Some(proxy.websocket_url().to_string()))?;
|
|
let backend = environment.get_exec_backend();
|
|
let temp_dir = TempDir::new()?;
|
|
let gate_path = temp_dir.path().join("release-output");
|
|
let emitted_path = temp_dir.path().join("output-emitted");
|
|
let session = backend
|
|
.start(ExecParams {
|
|
metadata: Default::default(),
|
|
process_id: ProcessId::from("proc-recover"),
|
|
argv: vec![
|
|
"/bin/sh".to_string(),
|
|
"-c".to_string(),
|
|
concat!(
|
|
"printf 'ready:%s\\n' \"$$\"; ",
|
|
"while [ ! -f \"$GATE\" ]; do /bin/sleep 0.01; done; ",
|
|
"printf 'during:%s\\n' \"$$\"; ",
|
|
": > \"$EMITTED\"; ",
|
|
"IFS= read -r line; ",
|
|
"printf 'after:%s:%s\\n' \"$$\" \"$line\"; ",
|
|
"exit 7",
|
|
)
|
|
.to_string(),
|
|
],
|
|
cwd: PathUri::from_host_native_path(std::env::current_dir()?)?,
|
|
shell_snapshot: None,
|
|
env_policy: /*env_policy*/ None,
|
|
env: HashMap::from([
|
|
(
|
|
"GATE".to_string(),
|
|
gate_path.to_string_lossy().into_owned(),
|
|
),
|
|
(
|
|
"EMITTED".to_string(),
|
|
emitted_path.to_string_lossy().into_owned(),
|
|
),
|
|
]),
|
|
tty: false,
|
|
pipe_stdin: true,
|
|
arg0: None,
|
|
sandbox: None,
|
|
enforce_managed_network: false,
|
|
managed_network: None,
|
|
network_proxy: None,
|
|
})
|
|
.await?;
|
|
|
|
let process = Arc::clone(&session.process);
|
|
let mut events = process.subscribe_events();
|
|
let mut output = Vec::new();
|
|
let mut last_seq = 0;
|
|
while !output.ends_with(b"\n") {
|
|
match timeout(Duration::from_secs(5), events.recv()).await?? {
|
|
ExecProcessEvent::Output(chunk) => {
|
|
assert_eq!(chunk.seq, last_seq + 1);
|
|
last_seq = chunk.seq;
|
|
output.extend_from_slice(&chunk.chunk.into_inner());
|
|
}
|
|
event => anyhow::bail!("expected ready output before disconnect, got {event:?}"),
|
|
}
|
|
}
|
|
let ready = String::from_utf8(output.clone())?;
|
|
let pid = ready
|
|
.strip_prefix("ready:")
|
|
.and_then(|line| line.strip_suffix('\n'))
|
|
.context("ready output should contain the process id")?
|
|
.to_string();
|
|
|
|
proxy.pause_and_disconnect().await?;
|
|
tokio::fs::write(&gate_path, b"").await?;
|
|
timeout(Duration::from_secs(5), async {
|
|
while tokio::fs::metadata(&emitted_path).await.is_err() {
|
|
sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.context("process did not emit output while disconnected")?;
|
|
|
|
let process_for_read = Arc::clone(&process);
|
|
let mut pending_read = tokio::spawn(async move {
|
|
process_for_read
|
|
.read(
|
|
/*after_seq*/ Some(last_seq),
|
|
/*max_bytes*/ None,
|
|
/*wait_ms*/ Some(0),
|
|
)
|
|
.await
|
|
});
|
|
assert!(
|
|
timeout(Duration::from_millis(200), &mut pending_read)
|
|
.await
|
|
.is_err(),
|
|
"process reads should wait while recovery is in progress"
|
|
);
|
|
proxy.resume()?;
|
|
|
|
let recovered_read = timeout(Duration::from_secs(5), pending_read)
|
|
.await
|
|
.context("timed out waiting for a read after recovery")??;
|
|
let recovered_read = recovered_read?;
|
|
assert_eq!(recovered_read.failure, None);
|
|
let recovered_output = recovered_read
|
|
.chunks
|
|
.into_iter()
|
|
.flat_map(|chunk| chunk.chunk.into_inner())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
String::from_utf8(recovered_output)?,
|
|
format!("during:{pid}\n")
|
|
);
|
|
|
|
let write = timeout(Duration::from_secs(5), process.write(b"hello\n".to_vec()))
|
|
.await
|
|
.context("timed out waiting for a write after recovery")??;
|
|
assert_eq!(write.status, WriteStatus::Accepted);
|
|
|
|
let mut saw_exit = false;
|
|
loop {
|
|
match timeout(Duration::from_secs(5), events.recv()).await?? {
|
|
ExecProcessEvent::Output(chunk) => {
|
|
assert_eq!(chunk.seq, last_seq + 1);
|
|
last_seq = chunk.seq;
|
|
output.extend_from_slice(&chunk.chunk.into_inner());
|
|
}
|
|
ExecProcessEvent::Exited { seq, exit_code, .. } => {
|
|
assert_eq!(seq, last_seq + 1);
|
|
assert_eq!(exit_code, 7);
|
|
last_seq = seq;
|
|
saw_exit = true;
|
|
}
|
|
ExecProcessEvent::Closed { seq } => {
|
|
assert!(saw_exit, "closed must be delivered after exit");
|
|
assert_eq!(seq, last_seq + 1);
|
|
break;
|
|
}
|
|
ExecProcessEvent::Failed(message) => {
|
|
anyhow::bail!("process recovery failed: {message}");
|
|
}
|
|
}
|
|
}
|
|
assert_eq!(
|
|
String::from_utf8(output)?,
|
|
format!("ready:{pid}\nduring:{pid}\nafter:{pid}:hello\n")
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test_case(false ; "local")]
|
|
#[test_case(true ; "remote")]
|
|
#[cfg_attr(not(unix), ignore = "Unix-only exec-server process test")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
// Serialize tests that launch a real exec-server process through the full CLI.
|
|
#[serial_test::serial(remote_exec_server)]
|
|
async fn exec_process_starts_and_exits(use_remote: bool) -> Result<()> {
|
|
assert_exec_process_starts_and_exits(use_remote).await
|
|
}
|
|
|
|
#[test_case(false ; "local")]
|
|
#[test_case(true ; "remote")]
|
|
#[cfg_attr(not(unix), ignore = "Unix-only exec-server process test")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
// Serialize tests that launch a real exec-server process through the full CLI.
|
|
#[serial_test::serial(remote_exec_server)]
|
|
async fn exec_process_streams_output(use_remote: bool) -> Result<()> {
|
|
assert_exec_process_streams_output(use_remote).await
|
|
}
|
|
|
|
#[test_case(false ; "local")]
|
|
#[test_case(true ; "remote")]
|
|
#[cfg_attr(not(unix), ignore = "Unix-only exec-server process test")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
// Serialize tests that launch a real exec-server process through the full CLI.
|
|
#[serial_test::serial(remote_exec_server)]
|
|
async fn exec_process_pushes_events(use_remote: bool) -> Result<()> {
|
|
assert_exec_process_pushes_events(use_remote).await
|
|
}
|
|
|
|
#[test_case(false ; "local")]
|
|
#[test_case(true ; "remote")]
|
|
#[cfg_attr(not(unix), ignore = "Unix-only exec-server process test")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
// Serialize tests that launch a real exec-server process through the full CLI.
|
|
#[serial_test::serial(remote_exec_server)]
|
|
async fn exec_process_replays_events_after_close(use_remote: bool) -> Result<()> {
|
|
assert_exec_process_replays_events_after_close(use_remote).await
|
|
}
|
|
|
|
#[test_case(false ; "local")]
|
|
#[test_case(true ; "remote")]
|
|
#[cfg_attr(not(unix), ignore = "Unix-only exec-server process test")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
// Serialize tests that launch a real exec-server process through the full CLI.
|
|
#[serial_test::serial(remote_exec_server)]
|
|
async fn exec_process_retains_output_after_exit_until_streams_close(
|
|
use_remote: bool,
|
|
) -> Result<()> {
|
|
assert_exec_process_retains_output_after_exit_until_streams_close(use_remote).await
|
|
}
|
|
|
|
#[test_case(false ; "local")]
|
|
#[test_case(true ; "remote")]
|
|
#[cfg_attr(not(unix), ignore = "Unix-only exec-server process test")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
// Serialize tests that launch a real exec-server process through the full CLI.
|
|
#[serial_test::serial(remote_exec_server)]
|
|
async fn exec_process_write_then_read(use_remote: bool) -> Result<()> {
|
|
assert_exec_process_write_then_read(use_remote).await
|
|
}
|
|
|
|
#[test_case(false ; "local")]
|
|
#[test_case(true ; "remote")]
|
|
#[cfg_attr(not(unix), ignore = "Unix-only exec-server process test")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
// Serialize tests that launch a real exec-server process through the full CLI.
|
|
#[serial_test::serial(remote_exec_server)]
|
|
async fn exec_process_write_then_read_without_tty(use_remote: bool) -> Result<()> {
|
|
assert_exec_process_write_then_read_without_tty(use_remote).await
|
|
}
|
|
|
|
#[test_case(
|
|
codex_sandboxing::SandboxType::WindowsRestrictedToken,
|
|
false;
|
|
"restricted_token"
|
|
)]
|
|
#[test_case(
|
|
codex_sandboxing::SandboxType::WindowsMxc,
|
|
false;
|
|
"mxc_pipe"
|
|
)]
|
|
#[test_case(
|
|
codex_sandboxing::SandboxType::WindowsMxc,
|
|
true;
|
|
"mxc_conpty"
|
|
)]
|
|
#[cfg_attr(not(windows), ignore = "Windows-only exec-server sandbox process test")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[serial_test::serial(remote_exec_server)]
|
|
async fn remote_windows_sandbox_process_accepts_process_write(
|
|
expected_sandbox_type: codex_sandboxing::SandboxType,
|
|
tty: bool,
|
|
) -> Result<()> {
|
|
assert_remote_windows_sandbox_process_write(expected_sandbox_type, tty).await
|
|
}
|
|
|
|
#[test_case(false ; "local")]
|
|
#[test_case(true ; "remote")]
|
|
#[cfg_attr(not(unix), ignore = "Unix-only exec-server process test")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
// Serialize tests that launch a real exec-server process through the full CLI.
|
|
#[serial_test::serial(remote_exec_server)]
|
|
async fn exec_process_rejects_write_without_pipe_stdin(use_remote: bool) -> Result<()> {
|
|
assert_exec_process_rejects_write_without_pipe_stdin(use_remote).await
|
|
}
|
|
|
|
#[test_case(false ; "local")]
|
|
#[test_case(true ; "remote")]
|
|
#[cfg_attr(not(unix), ignore = "Unix-only exec-server process test")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
// Serialize tests that launch a real exec-server process through the full CLI.
|
|
#[serial_test::serial(remote_exec_server)]
|
|
async fn exec_process_signal_interrupts_process(use_remote: bool) -> Result<()> {
|
|
assert_exec_process_signal_interrupts_process(use_remote).await
|
|
}
|
|
|
|
#[test_case(false ; "local")]
|
|
#[test_case(true ; "remote")]
|
|
#[cfg_attr(not(windows), ignore = "Windows-only exec-server process test")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
// Serialize tests that launch a real exec-server process through the full CLI.
|
|
#[serial_test::serial(remote_exec_server)]
|
|
async fn exec_process_signal_terminates_on_windows(use_remote: bool) -> Result<()> {
|
|
assert_exec_process_signal_terminates_on_windows(use_remote).await
|
|
}
|
|
|
|
#[test_case(false ; "local")]
|
|
#[test_case(true ; "remote")]
|
|
#[cfg_attr(not(unix), ignore = "Unix-only exec-server process test")]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
// Serialize tests that launch a real exec-server process through the full CLI.
|
|
#[serial_test::serial(remote_exec_server)]
|
|
async fn exec_process_preserves_queued_events_before_subscribe(use_remote: bool) -> Result<()> {
|
|
assert_exec_process_preserves_queued_events_before_subscribe(use_remote).await
|
|
}
|