mirror of
https://github.com/openai/codex.git
synced 2026-10-16 16:58:46 +00:00
Add in-memory shell snapshots to unified exec (#39957)
## What changed - Add the under-development `shell_snapshot_v2` feature and advertise executor support through environment capabilities. - Use executor-managed, in-memory snapshots for eligible direct `bash`, `zsh`, and `sh` login commands, while applying the configured shell environment policy and avoiding duplicate automatic startup-file effects. - Keep file-backed snapshots available for user-shell commands and fall back to the existing execution path when in-memory snapshots are unsupported or inapplicable. ## Testing - Cover policy filtering, snapshot reuse without snapshot files, automatic startup files, local and remote execution, and legacy user-shell snapshots. GitOrigin-RevId: 4ad6cdf13824913ac8c393ba38b9844230677579
This commit is contained in:
@@ -754,6 +754,9 @@
|
||||
"shell_snapshot": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"shell_snapshot_v2": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"shell_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -5712,6 +5715,9 @@
|
||||
"shell_snapshot": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"shell_snapshot_v2": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"shell_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -137,6 +137,7 @@ struct ResolvedEnvironment {
|
||||
environment: Arc<Environment>,
|
||||
shell: Option<Shell>,
|
||||
shell_snapshot: ShellSnapshotTask,
|
||||
shell_snapshot_v2_supported: bool,
|
||||
installed_config: Option<EnvironmentConfig>,
|
||||
}
|
||||
|
||||
@@ -239,6 +240,7 @@ impl ThreadEnvironments {
|
||||
environment: environment.environment,
|
||||
shell: environment.shell,
|
||||
shell_snapshot: environment.shell_snapshot,
|
||||
shell_snapshot_v2_supported: environment.shell_snapshot_v2_supported,
|
||||
installed_config: None,
|
||||
}))
|
||||
.boxed()
|
||||
@@ -602,24 +604,27 @@ impl ThreadEnvironments {
|
||||
};
|
||||
// Resolve the attachment only after both prerequisites are ready.
|
||||
let ((), installed_config) = tokio::try_join!(connection_ready, configuration_ready)?;
|
||||
let shell = if environment.is_remote() {
|
||||
let (shell, shell_snapshot_v2_supported) = if environment.is_remote() {
|
||||
match environment.info().await {
|
||||
Ok(info) => match Shell::from_environment_shell_info(info.shell) {
|
||||
Ok(shell) => Some(shell),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"failed to resolve shell for environment `{environment_id}`: {err}"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
Ok(info) => (
|
||||
match Shell::from_environment_shell_info(info.shell) {
|
||||
Ok(shell) => Some(shell),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"failed to resolve shell for environment `{environment_id}`: {err}"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
info.capabilities.shell_snapshot_v2,
|
||||
),
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to get info for environment `{environment_id}`: {err}");
|
||||
None
|
||||
(None, false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Some(local_shell)
|
||||
(Some(local_shell), cfg!(unix))
|
||||
};
|
||||
let task = shell_snapshot
|
||||
.build(Arc::clone(&environment), selection.cwd, shell.clone())
|
||||
@@ -632,6 +637,7 @@ impl ThreadEnvironments {
|
||||
environment,
|
||||
shell,
|
||||
shell_snapshot: task,
|
||||
shell_snapshot_v2_supported,
|
||||
installed_config,
|
||||
})
|
||||
}
|
||||
@@ -707,6 +713,8 @@ impl TurnEnvironmentState {
|
||||
environment.shell,
|
||||
);
|
||||
turn_environment.shell_snapshot = environment.shell_snapshot;
|
||||
turn_environment.shell_snapshot_v2_supported =
|
||||
environment.shell_snapshot_v2_supported;
|
||||
Some(Self::Ready(turn_environment))
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
|
||||
@@ -408,6 +408,7 @@ pub(crate) async fn execute_exec_request(
|
||||
cwd,
|
||||
env,
|
||||
exec_server_env_config: _,
|
||||
exec_server_shell_snapshot: _,
|
||||
network,
|
||||
expiration,
|
||||
capture_policy,
|
||||
|
||||
@@ -52,6 +52,7 @@ pub struct ExecRequest {
|
||||
pub cwd: PathUri,
|
||||
pub env: HashMap<String, String>,
|
||||
pub(crate) exec_server_env_config: Option<ExecServerEnvConfig>,
|
||||
pub(crate) exec_server_shell_snapshot: Option<codex_exec_server::ShellSnapshotRequest>,
|
||||
pub network: Option<NetworkProxy>,
|
||||
pub network_environment_id: Option<String>,
|
||||
pub expiration: ExecExpiration,
|
||||
@@ -94,6 +95,7 @@ impl ExecRequest {
|
||||
cwd,
|
||||
env,
|
||||
exec_server_env_config: None,
|
||||
exec_server_shell_snapshot: None,
|
||||
network,
|
||||
network_environment_id,
|
||||
expiration,
|
||||
@@ -182,6 +184,7 @@ impl ExecRequest {
|
||||
cwd,
|
||||
env,
|
||||
exec_server_env_config: None,
|
||||
exec_server_shell_snapshot: None,
|
||||
network,
|
||||
network_environment_id,
|
||||
expiration,
|
||||
|
||||
@@ -1164,7 +1164,21 @@ impl Session {
|
||||
} else {
|
||||
shell::default_user_shell()
|
||||
};
|
||||
let shell_snapshot = if config.features.enabled(Feature::ShellSnapshot) {
|
||||
let use_executor_shell_snapshots = config.features.enabled(Feature::ShellSnapshotV2)
|
||||
&& config.features.enabled(Feature::ShellTool)
|
||||
&& config.features.enabled(Feature::UnifiedExec)
|
||||
&& matches!(
|
||||
codex_tools::UnifiedExecShellMode::for_session(
|
||||
config.features.get(),
|
||||
crate::tools::tool_user_shell_type(&default_shell),
|
||||
config.zsh_path.as_ref(),
|
||||
config.main_execve_wrapper_exe.as_ref(),
|
||||
),
|
||||
codex_tools::UnifiedExecShellMode::Direct
|
||||
);
|
||||
let shell_snapshot = if config.features.enabled(Feature::ShellSnapshot)
|
||||
&& !use_executor_shell_snapshots
|
||||
{
|
||||
ShellSnapshot::new(
|
||||
config.codex_home.clone(),
|
||||
thread_id,
|
||||
|
||||
@@ -42,6 +42,7 @@ pub(crate) struct TurnEnvironment {
|
||||
pub(crate) environment: Arc<Environment>,
|
||||
pub(crate) shell: Option<shell::Shell>,
|
||||
pub(crate) shell_snapshot: ShellSnapshotTask,
|
||||
pub(crate) shell_snapshot_v2_supported: bool,
|
||||
}
|
||||
|
||||
impl TurnEnvironment {
|
||||
@@ -58,6 +59,7 @@ impl TurnEnvironment {
|
||||
environment,
|
||||
shell,
|
||||
shell_snapshot: futures::future::ready(None).boxed().shared(),
|
||||
shell_snapshot_v2_supported: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ pub(crate) async fn execute_user_shell_command(
|
||||
cwd: cwd.clone().into(),
|
||||
env: exec_env_map,
|
||||
exec_server_env_config: None,
|
||||
exec_server_shell_snapshot: None,
|
||||
// `/shell` is the explicit full-access escape hatch, so it must not
|
||||
// inherit a managed proxy from the surrounding session or turn.
|
||||
network: None,
|
||||
|
||||
@@ -71,6 +71,7 @@ pub struct UnifiedExecRequest {
|
||||
pub turn_environment: TurnEnvironment,
|
||||
pub env: HashMap<String, String>,
|
||||
pub exec_server_env_config: Option<ExecServerEnvConfig>,
|
||||
pub shell_snapshot: Option<codex_exec_server::ShellSnapshotRequest>,
|
||||
pub explicit_env_overrides: HashMap<String, String>,
|
||||
pub network: Option<NetworkProxy>,
|
||||
pub tty: bool,
|
||||
@@ -201,7 +202,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
|
||||
}
|
||||
|
||||
fn uses_executor_managed_process_sandbox(&self, req: &UnifiedExecRequest) -> bool {
|
||||
req.turn_environment.environment.is_remote()
|
||||
req.turn_environment.environment.is_remote() || req.shell_snapshot.is_some()
|
||||
}
|
||||
|
||||
fn sandbox_cwd<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b PathUri> {
|
||||
@@ -368,7 +369,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let runtime_path_prepends = RuntimePathPrepends::default();
|
||||
let command = if environment_is_remote {
|
||||
let mut command = if environment_is_remote {
|
||||
base_command.to_vec()
|
||||
} else {
|
||||
maybe_wrap_shell_lc_with_snapshot(
|
||||
@@ -380,6 +381,15 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
|
||||
&runtime_path_prepends,
|
||||
)
|
||||
};
|
||||
if req.shell_snapshot.is_some() {
|
||||
let exports =
|
||||
runtime_path_prepends.shell_exports_after_snapshot(&explicit_env_overrides);
|
||||
if !exports.is_empty()
|
||||
&& let Some(script) = command.get_mut(2)
|
||||
{
|
||||
*script = format!("{exports}\n{script}");
|
||||
}
|
||||
}
|
||||
let command = disable_powershell_profile_for_elevated_windows_sandbox(
|
||||
&command,
|
||||
Some(&req.shell_type),
|
||||
@@ -491,6 +501,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
|
||||
network_proxy_launch,
|
||||
/*environment_id*/ Some(&req.turn_environment.selection.environment_id),
|
||||
req.exec_server_env_config.clone(),
|
||||
req.shell_snapshot.clone(),
|
||||
windows_sandbox_proxy_settings_mode,
|
||||
req.tty,
|
||||
Box::new(NoopSpawnLifecycle),
|
||||
@@ -616,6 +627,7 @@ mod tests {
|
||||
turn_environment: test_turn_environment(sandbox_cwd.clone().into()),
|
||||
env: HashMap::new(),
|
||||
exec_server_env_config: None,
|
||||
shell_snapshot: None,
|
||||
explicit_env_overrides: HashMap::new(),
|
||||
network: None,
|
||||
tty: false,
|
||||
@@ -718,6 +730,7 @@ mod tests {
|
||||
turn_environment: test_turn_environment(cwd.into()),
|
||||
env: HashMap::new(),
|
||||
exec_server_env_config: None,
|
||||
shell_snapshot: None,
|
||||
explicit_env_overrides: HashMap::new(),
|
||||
network: None,
|
||||
tty: false,
|
||||
|
||||
@@ -632,6 +632,7 @@ impl CoreShellCommandExecutor {
|
||||
cwd: self.cwd.clone().into(),
|
||||
env: exec_env,
|
||||
exec_server_env_config: None,
|
||||
exec_server_shell_snapshot: None,
|
||||
network: self.network.clone(),
|
||||
network_environment_id: self.network_environment_id.clone(),
|
||||
expiration: ExecExpiration::Cancellation(cancel_rx),
|
||||
|
||||
@@ -51,6 +51,7 @@ mod head_tail_buffer;
|
||||
mod process;
|
||||
mod process_manager;
|
||||
mod process_state;
|
||||
mod shell_snapshot;
|
||||
|
||||
pub(crate) fn set_deterministic_process_ids_for_tests(enabled: bool) {
|
||||
process_manager::set_deterministic_process_ids_for_tests(enabled);
|
||||
|
||||
@@ -60,6 +60,7 @@ use crate::unified_exec::head_tail_buffer::HeadTailBuffer;
|
||||
use crate::unified_exec::process::OutputHandles;
|
||||
use crate::unified_exec::process::SpawnLifecycleHandle;
|
||||
use crate::unified_exec::process::UnifiedExecProcess;
|
||||
use crate::unified_exec::shell_snapshot::shell_snapshot_request;
|
||||
use crate::unified_exec::take_plugin_metrics_sidecar;
|
||||
use codex_core_plugins::PLUGIN_METRICS_OUTPUT_ENV_VAR;
|
||||
use codex_core_plugins::PluginCommandAttribution;
|
||||
@@ -186,6 +187,11 @@ fn exec_server_env_for_request(
|
||||
if let Some(exec_server_env_config) = &request.exec_server_env_config {
|
||||
let mut env =
|
||||
env_overlay_for_exec_server(&request.env, &exec_server_env_config.local_policy_env);
|
||||
if request.exec_server_shell_snapshot.is_some()
|
||||
&& !exec_server_env_config.policy.r#set.contains_key("PATH")
|
||||
{
|
||||
env.remove("PATH");
|
||||
}
|
||||
if request.exec_server_managed_network.is_some() {
|
||||
for (key, value) in &request.env {
|
||||
if is_managed_proxy_env_var(key, value) {
|
||||
@@ -210,18 +216,20 @@ fn exec_server_params_for_request(
|
||||
sandbox.windows_sandbox_proxy_settings_mode = Some(windows_sandbox_proxy_settings_mode);
|
||||
sandbox
|
||||
});
|
||||
// Sandbox retries reuse the unified-exec ID but start a distinct executor process.
|
||||
let exec_server_process_id = if request.exec_server_sandbox.is_some() {
|
||||
format!("{process_id}-{}", Uuid::new_v4())
|
||||
} else {
|
||||
process_id.to_string()
|
||||
};
|
||||
// Sandbox retries and memory-backed local launches can reuse a unified-exec
|
||||
// ID while the executor still retains the previous process.
|
||||
let exec_server_process_id =
|
||||
if request.exec_server_sandbox.is_some() || request.exec_server_shell_snapshot.is_some() {
|
||||
format!("{process_id}-{}", Uuid::new_v4())
|
||||
} else {
|
||||
process_id.to_string()
|
||||
};
|
||||
codex_exec_server::ExecParams {
|
||||
process_id: exec_server_process_id.into(),
|
||||
argv: request.command.clone(),
|
||||
cwd: request.cwd.clone(),
|
||||
env_policy,
|
||||
shell_snapshot: None,
|
||||
shell_snapshot: request.exec_server_shell_snapshot.clone(),
|
||||
env,
|
||||
tty,
|
||||
pipe_stdin: false,
|
||||
@@ -1060,12 +1068,13 @@ impl UnifiedExecProcessManager {
|
||||
network_proxy_launch: Option<codex_network_proxy::RemoteNetworkProxyLaunchConfig>,
|
||||
environment_id: Option<&str>,
|
||||
exec_server_env_config: Option<ExecServerEnvConfig>,
|
||||
shell_snapshot: Option<codex_exec_server::ShellSnapshotRequest>,
|
||||
windows_sandbox_proxy_settings_mode: codex_sandboxing::WindowsSandboxProxySettingsMode,
|
||||
tty: bool,
|
||||
spawn_lifecycle: SpawnLifecycleHandle,
|
||||
environment: &codex_exec_server::Environment,
|
||||
) -> Result<UnifiedExecProcess, ToolError> {
|
||||
let mut request = if environment.is_remote() {
|
||||
let mut request = if environment.is_remote() || shell_snapshot.is_some() {
|
||||
attempt.env_for_exec_server(command, options)
|
||||
} else {
|
||||
attempt.env_for(command, options, network, environment_id)
|
||||
@@ -1077,6 +1086,7 @@ impl UnifiedExecProcessManager {
|
||||
.and_then(|_| network.and_then(NetworkProxy::remote_policy_decider));
|
||||
request.exec_server_network_proxy = network_proxy_launch;
|
||||
request.exec_server_env_config = exec_server_env_config;
|
||||
request.exec_server_shell_snapshot = shell_snapshot;
|
||||
self.open_session_with_prepared_exec_env(
|
||||
process_id,
|
||||
&request,
|
||||
@@ -1111,7 +1121,7 @@ impl UnifiedExecProcessManager {
|
||||
) -> Result<UnifiedExecProcess, UnifiedExecError> {
|
||||
let inherited_fds = spawn_lifecycle.inherited_fds();
|
||||
|
||||
if environment.is_remote() {
|
||||
if environment.is_remote() || request.exec_server_shell_snapshot.is_some() {
|
||||
if !inherited_fds.is_empty() {
|
||||
return Err(UnifiedExecError::create_process(
|
||||
"remote exec-server does not support inherited file descriptors".to_string(),
|
||||
@@ -1237,6 +1247,7 @@ impl UnifiedExecProcessManager {
|
||||
policy: exec_env_policy_from_shell_policy(shell_environment_policy),
|
||||
local_policy_env,
|
||||
};
|
||||
let shell_snapshot = shell_snapshot_request(request, &cwd, context);
|
||||
let mut orchestrator = ToolOrchestrator::new();
|
||||
let mut runtime = UnifiedExecRuntime::new(self, request.shell_mode.clone());
|
||||
let session_shell = context.session.user_shell();
|
||||
@@ -1278,6 +1289,7 @@ impl UnifiedExecProcessManager {
|
||||
turn_environment: request.turn_environment.clone(),
|
||||
env,
|
||||
exec_server_env_config: Some(exec_server_env_config),
|
||||
shell_snapshot,
|
||||
explicit_env_overrides,
|
||||
network: request.network.clone(),
|
||||
tty: request.tty,
|
||||
|
||||
@@ -184,6 +184,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() {
|
||||
),
|
||||
]),
|
||||
}),
|
||||
exec_server_shell_snapshot: None,
|
||||
network: None,
|
||||
network_environment_id: None,
|
||||
expiration: crate::exec::ExecExpiration::DefaultTimeout,
|
||||
@@ -230,6 +231,18 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() {
|
||||
("CODEX_NETWORK_PROXY_ACTIVE".to_string(), "1".to_string(),),
|
||||
])
|
||||
);
|
||||
request.exec_server_shell_snapshot = Some(codex_exec_server::ShellSnapshotRequest {
|
||||
scope_id: "attachment-1".to_string(),
|
||||
shell: codex_exec_server::ShellInfo {
|
||||
name: "bash".to_string(),
|
||||
path: "/bin/bash".to_string(),
|
||||
},
|
||||
});
|
||||
let mut snapshot_env = params.env;
|
||||
snapshot_env.remove("PATH");
|
||||
assert_eq!(params_for_request(&request).env, snapshot_env);
|
||||
request.exec_server_shell_snapshot = None;
|
||||
|
||||
request.exec_server_sandbox = Some(
|
||||
codex_exec_server::FileSystemSandboxContext::from_permission_profile(permission_profile),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
use codex_exec_server::ShellInfo;
|
||||
use codex_exec_server::ShellSnapshotRequest;
|
||||
use codex_features::Feature;
|
||||
use codex_tools::UnifiedExecShellMode;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
|
||||
use super::ExecCommandRequest;
|
||||
use super::UnifiedExecContext;
|
||||
use crate::shell::ShellType;
|
||||
|
||||
pub(super) fn shell_snapshot_request(
|
||||
request: &ExecCommandRequest,
|
||||
cwd: &PathUri,
|
||||
context: &UnifiedExecContext,
|
||||
) -> Option<ShellSnapshotRequest> {
|
||||
if !context.session.features().enabled(Feature::ShellSnapshotV2)
|
||||
|| !request.turn_environment.shell_snapshot_v2_supported
|
||||
|| request.turn_environment.selection.cwd != *cwd
|
||||
|| !matches!(request.shell_mode, UnifiedExecShellMode::Direct)
|
||||
|| !matches!(
|
||||
request.shell_type,
|
||||
ShellType::Bash | ShellType::Zsh | ShellType::Sh
|
||||
)
|
||||
|| request.command.get(1).is_none_or(|flag| flag != "-lc")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ShellSnapshotRequest {
|
||||
scope_id: format!(
|
||||
"{}:{}",
|
||||
context.session.thread_id(),
|
||||
request.turn_environment.selection.environment_id
|
||||
),
|
||||
shell: ShellInfo {
|
||||
name: request.shell_type.name().to_string(),
|
||||
path: request.command.first()?.clone(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use codex_core::TurnInputRequest;
|
||||
#[cfg(target_os = "macos")]
|
||||
#[cfg(unix)]
|
||||
use codex_core::shell::get_shell_by_model_provided_path;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
@@ -20,6 +20,8 @@ use core_test_support::responses::ev_function_call;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::sse;
|
||||
#[cfg(unix)]
|
||||
use core_test_support::skip_if_remote;
|
||||
use core_test_support::test_codex::TestCodexHarness;
|
||||
use core_test_support::test_codex::local_selections;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
@@ -290,6 +292,112 @@ fn assert_posix_snapshot_sections(snapshot: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shell_snapshot_v2_filters_profile_secrets_without_creating_files() -> Result<()> {
|
||||
skip_if_remote!(Ok(()), "profile fixture uses a host-local HOME directory");
|
||||
let profile_home = tempfile::tempdir()?;
|
||||
fs::write(
|
||||
profile_home.path().join(".bashrc"),
|
||||
"printf x >> \"$HOME/captures\"\nexport PATH=\"$HOME/profile-bin:$PATH\"\nexport PROFILE_ALLOWED=profile\nexport PROFILE_SECRET=secret\nprofile_helper() { printf helper; }\n",
|
||||
)
|
||||
.await?;
|
||||
fs::write(
|
||||
profile_home.path().join(".bash_profile"),
|
||||
"export PROFILE_SECRET=secret\n",
|
||||
)
|
||||
.await?;
|
||||
let configured_home = profile_home.path().to_string_lossy().into_owned();
|
||||
let shell = get_shell_by_model_provided_path(&PathBuf::from("/bin/sh"));
|
||||
let builder = test_codex()
|
||||
.with_user_shell(shell)
|
||||
.with_config(move |config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::UnifiedExec)
|
||||
.expect("test config should enable unified exec");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::ShellSnapshotV2)
|
||||
.expect("test config should enable in-memory snapshots");
|
||||
config
|
||||
.permissions
|
||||
.shell_environment_policy
|
||||
.ignore_default_excludes = false;
|
||||
config.permissions.shell_environment_policy.r#set = HashMap::from([
|
||||
("HOME".to_string(), configured_home),
|
||||
("PROFILE_ALLOWED".to_string(), "policy".to_string()),
|
||||
]);
|
||||
});
|
||||
let harness = TestCodexHarness::with_auto_env_builder(builder).await?;
|
||||
|
||||
for attempt in 0..2 {
|
||||
let end = run_tool_turn_on_harness(
|
||||
&harness,
|
||||
"run an in-memory shell snapshot",
|
||||
&format!("shell-snapshot-v2-{attempt}"),
|
||||
"exec_command",
|
||||
json!({
|
||||
"cmd": "profile_helper; case \":$PATH:\" in *\":$HOME/profile-bin:\"*) printf '|path';; *) printf '|missing';; esac; printf '|%s|%s' \"$PROFILE_ALLOWED\" \"${PROFILE_SECRET-missing}\"",
|
||||
"shell": "/bin/bash",
|
||||
"yield_time_ms": 1_000,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(end.exit_code, 0);
|
||||
assert_eq!(
|
||||
normalize_newlines(&end.stdout).trim(),
|
||||
"helper|path|policy|missing"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(profile_home.path().join("captures")).await?,
|
||||
"x"
|
||||
);
|
||||
assert!(!harness.test().home.path().join("shell_snapshots").exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shell_snapshot_v2_preserves_legacy_snapshots_for_user_shell() -> Result<()> {
|
||||
skip_if_remote!(Ok(()), "legacy shell snapshots require a local environment");
|
||||
let builder = test_codex().with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.disable(Feature::ShellTool)
|
||||
.expect("test config should disable model shell tools");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::ShellSnapshot)
|
||||
.expect("test config should enable legacy snapshots");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::ShellSnapshotV2)
|
||||
.expect("test config should enable in-memory snapshots");
|
||||
});
|
||||
let harness = TestCodexHarness::with_auto_env_builder(builder).await?;
|
||||
let snapshot_path = wait_for_snapshot(harness.test().home.path()).await?;
|
||||
assert_posix_snapshot_sections(&fs::read_to_string(snapshot_path).await?);
|
||||
let codex = &harness.test().codex;
|
||||
codex
|
||||
.submit(Op::RunUserShellCommand {
|
||||
command: "printf legacy".to_string(),
|
||||
})
|
||||
.await?;
|
||||
let end = wait_for_event_match(codex, |event| match event {
|
||||
EventMsg::ExecCommandEnd(event) => Some(event.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(end.exit_code, 0);
|
||||
assert_eq!(normalize_newlines(&end.stdout).trim(), "legacy");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "linux"), ignore)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn linux_unified_exec_uses_shell_snapshot() -> Result<()> {
|
||||
|
||||
@@ -172,9 +172,17 @@ impl ShellSnapshotCache {
|
||||
.collect::<String>();
|
||||
let state_variables = state_variables.join(" ");
|
||||
let shell_start = prepared.command.len() - params.argv.len();
|
||||
prepared.command[shell_start + 1] = "-c".to_string();
|
||||
// Automatic startup files run before the restoration script and could
|
||||
// reintroduce environment variables that the snapshot already filtered.
|
||||
let (shell_flag, startup) = match shell_type {
|
||||
ShellType::Bash => ("-pc", "set +o privileged\n"),
|
||||
ShellType::Zsh => ("-fc", "setopt RCS\n"),
|
||||
ShellType::Sh => ("-c", ""),
|
||||
ShellType::PowerShell | ShellType::Cmd => unreachable!(),
|
||||
};
|
||||
prepared.command[shell_start + 1] = shell_flag.to_string();
|
||||
prepared.command[shell_start + 2] = format!(
|
||||
"if ! eval \"unset {state_variables}\n{state_expansion}\" >/dev/null; then printf 'failed to restore shell snapshot\\n' >&2; fi\n{}",
|
||||
"{startup}if ! eval \"unset {state_variables}\n{state_expansion}\" >/dev/null; then printf 'failed to restore shell snapshot\\n' >&2; fi\n{}",
|
||||
params.argv[2]
|
||||
);
|
||||
|
||||
|
||||
@@ -103,15 +103,25 @@ async fn create_process_context(use_remote: bool) -> Result<ProcessContext> {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test_case(false, false, false, "bash"; "local_pipe")]
|
||||
#[test_case(false, true, false, "bash"; "local_tty")]
|
||||
#[test_case(true, false, false, "bash"; "remote_pipe")]
|
||||
#[test_case(true, true, false, "bash"; "remote_tty")]
|
||||
#[test_case(true, false, true, "bash"; "remote_sandbox")]
|
||||
#[test_case(false, false, false, "sh"; "local_sh_pipe")]
|
||||
#[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, 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, "zsh"; "local_zsh_pipe")
|
||||
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.
|
||||
@@ -120,6 +130,7 @@ 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
|
||||
@@ -133,8 +144,10 @@ async fn shell_snapshot_v2_filters_profile_exports_and_stays_in_memory(
|
||||
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"),
|
||||
"zsh" if automatic_startup => ("/bin/zsh", ".zshenv"),
|
||||
"zsh" => ("/bin/zsh", ".zshrc"),
|
||||
name => anyhow::bail!("unsupported test shell {name}"),
|
||||
};
|
||||
@@ -160,6 +173,12 @@ async fn shell_snapshot_v2_filters_profile_exports_and_stays_in_memory(
|
||||
"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; }}\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(),
|
||||
@@ -170,12 +189,19 @@ async fn shell_snapshot_v2_filters_profile_exports_and_stays_in_memory(
|
||||
profile_path.to_string_lossy().into_owned(),
|
||||
);
|
||||
}
|
||||
if shell_name == "bash" && automatic_startup {
|
||||
configured_environment.insert(
|
||||
"BASH_ENV".to_string(),
|
||||
profile_path.to_string_lossy().into_owned(),
|
||||
);
|
||||
}
|
||||
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(),
|
||||
|
||||
@@ -145,6 +145,8 @@ pub enum Feature {
|
||||
UseLegacyLandlock,
|
||||
/// Experimental shell snapshotting.
|
||||
ShellSnapshot,
|
||||
/// Keep policy-filtered shell snapshots entirely in executor memory.
|
||||
ShellSnapshotV2,
|
||||
/// Allow turns to start while selected executors are still starting.
|
||||
DeferredExecutor,
|
||||
/// Use the current working directory for turn diff display paths.
|
||||
@@ -871,6 +873,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ShellSnapshotV2,
|
||||
key: "shell_snapshot_v2",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::DeferredExecutor,
|
||||
key: "deferred_executor",
|
||||
|
||||
Reference in New Issue
Block a user